Merge remote-tracking branch 'origin/master' into stage/6349
This commit is contained in:
commit
77d0b8609d
96 files changed
+15479
-757
No files matched your search
@@ -31,6 +31,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
scenario: [normal, terminal-error]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -53,12 +57,13 @@ jobs:
|
||||
- name: Run conversation lifecycle gate
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conversation-lifecycle-failure
|
||||
name: conversation-lifecycle-failure-${{ matrix.scenario }}
|
||||
path: lifecycle-artifacts
|
||||
if-no-files-found: ignore
|
||||
@@ -11,18 +11,74 @@
|
||||
|
||||
- **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258)
|
||||
|
||||
- **Internal: clarified the transcript-virtualization boot comment (opt-in default retained).** The comment above the boot-time `_virtualizeTranscript` apply now records that the #4346 Phase B footer-jitter fix resolved the original scroll-up flicker root cause, while virtualization stays opt-in (default OFF) until battle-tested further. Zero behavior change — the apply predicate, settings-load fallback, checkbox, and server default all remain opt-in/OFF and in agreement. Thanks @webtecnica. (#6318, #6155)
|
||||
|
||||
- **Docs: reconciled the assistant-reply lifecycle RFCs/contracts with shipped behavior.** The Stable Assistant Turn Anchors and Live-to-Final RFCs, `docs/CONTRACTS.md`, and the Phase 0 architecture inventory were updated from "proposed/scaffold" framing to "accepted/implemented" to match what actually ships (single assistant-turn owner projecting one `activity_scene_v1` into Compact Worklog / Transparent Stream / Final answer). Documentation-only — no runtime behavior or CI-gate change; the one code touch is a corrected comment header in `static/assistant_turn_anchors.js`. Remaining hardening stays tracked under #3400. Thanks @franksong2702. (#6144)
|
||||
|
||||
- **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145)
|
||||
|
||||
### Added
|
||||
|
||||
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gateway approval cards are now routed by run ID, so approvals and Stop always act on the right run (#6008).** A gateway approval card that arrived without an `approval_id` couldn't be acted on, and with multiple concurrent runs an approval or a Stop could be applied to the wrong run. Approvals are now owned per-`(session, run)`: identity-less and identity-bearing approvals route through the same single-flight owner, concurrent duplicate submits can't double-relay, different runs don't block each other, and a failed relay releases ownership so a retry can proceed. The composer/sidebar Stop now waits boundedly for an acknowledged exact-run gateway stop and surfaces a persistent, actionable "Cancel failed." error toast (with Copy and Dismiss, localized) instead of silently settling the UI when the stop fails. When multiple approvals are queued the card shows a "1 of N pending" counter. Thanks @rodboev. (#6040, #6008)
|
||||
|
||||
- **Streaming prose no longer appears one character short before a tool card in Compact Worklog.** While a turn was streaming, the process-prose immediately preceding a tool card could render with its last character missing until the whole turn settled — the streaming-markdown renderer holds the newest character back as lookahead until its parser is finalized, and Anchor-owned prose rows never finalized theirs. The cached parser is now finalized once when the row becomes `completed` (running the same media/link/table cleanup the main answer body already does), so the character appears immediately. Incremental O(n) rendering is preserved and an edit/re-render after finalize self-heals by rebuilding the parser. Thanks @starship-s. (#6408)
|
||||
|
||||
- **Security: public share links no longer leak local attachments (#6174).** A public share snapshot could carry `MEDIA:<local path>` references that the public renderer turned into authenticated `/api/media` requests, exposing local workspace/attachment files (and, via crafted paths, files outside them) to anyone with the link. Share snapshots now embed only images that resolve *inside* the session workspace or the attachment root, as inline base64 fixed at build time — enforced by a path allow-list (`is_relative_to`, so `..` traversal and symlink escapes are rejected), a 512 KiB size cap, an image-only MIME allow-list, and magic-byte content validation (an extension-spoofed file is rejected). `file://`, absolute paths outside the allowed roots, SVG, non-image files, and the empty-allowed-roots case all fail closed to a static placeholder — no local bytes ever reach the public page. Public `https://` image URLs still pass through unchanged. Thanks @webtecnica. (#6319, #6174)
|
||||
|
||||
- **The transcript no longer jumps when a streaming reply settles while you're reading earlier messages.** If you scrolled up to read history and a streaming assistant reply finished (collapsing its expanded worklog to the settled compact view), the viewport could lurch away from what you were reading. The scroll anchor is now captured from the live DOM *before* the collapse render and reused, so your position is preserved through settlement. Readers pinned to the bottom still auto-follow to the newest reply as before. Thanks @franksong2702. (#6390, #6385)
|
||||
|
||||
- **TypeScript source files served from the workspace no longer risk same-origin script execution.** `.ts` and `.tsx` files fetched via the file-raw path are now served as `text/plain` instead of an executable content type, closing an XSS vector where a workspace `.ts` file could run inline in the browser. `.js`/`.jsx` remain `application/octet-stream` (non-executable) as before, and no other content-type contract changed. Thanks @webtecnica. (#6372, #6374)
|
||||
|
||||
- **`config.yaml` is now written atomically so a crash mid-write can't corrupt it.** Config saves (settings, onboarding, and profile writes) previously used an in-place write, so a crash, full disk, or power loss between open and full write could leave `config.yaml` truncated or empty. All config writers now route through a shared same-directory temp file → `fsync` → `os.replace`, preserving the file's existing permission mode and writing through symlinks. Thanks @ai-ag2026. (#5797, #5774)
|
||||
|
||||
- **`ctl.sh` no longer double-starts a WebUI that another supervisor already owns.** `ctl.sh start` now detects a systemd-managed instance or any live server already answering on the port (mirroring `server.py`'s own already-serving check) and refuses to spawn a duplicate, and `stop`/`status` correctly surface a running-but-unmanaged foreign instance instead of reporting "stopped." Normal managed start/stop/status is unchanged. Thanks @ai-ag2026. (#5944)
|
||||
|
||||
- **Per-model reasoning-effort metadata is now honored by the WebUI reasoning picker.** When resolving which reasoning modes a model exposes, a `models.<model>.reasoning_efforts` list (as Hermes Agent stores under `custom_providers[].models`) now takes precedence over the provider-wide `reasoning_efforts` list, falling back to the provider list and then heuristics. This lets a model with its own declared modes (e.g. an unrecognized custom model) expose them without a provider-wide override. Configs that only set a provider-level list are completely unaffected — output is byte-identical to before. Thanks @CharlesMcquade. (#6107)
|
||||
|
||||
- **Stream teardown no longer leaks per-stream runtime state on long-running servers.** When a stream ended, its `STREAM_SESSION_OWNERS` owner mapping and (for gateway turns) the `AGENT_INSTANCES` entry were retained, so a high-volume, long-lived server slowly accumulated dead per-stream bookkeeping. Teardown now unregisters the stream owner and drops the gateway agent instance. The offline-frame buffer is deliberately **not** cleared on teardown so a late reconnect can still replay buffered `token`/`done`/`stream_end` frames; buffers remain bounded (8,192 frames) and channels are reclaimed once outstanding reconnect references exit. Thanks @webtecnica. (#6389, #6351)
|
||||
|
||||
- **Japanese UI is fully translated again.** The Japanese (`ja`) locale had drifted to English fallback across recent features (workspace panel, session sharing, extensions gallery, cron sentence-builder, composer, settings, and update flows); it is now refreshed to parity with the current English keys — 180 strings — so Japanese users no longer see a mixed-language UI. Thanks @koshikai. (#6403)
|
||||
|
||||
- **Reloaded tool-call turns render with the same unified Worklog as live turns.** Historical transcripts using ID-linked tool calls (OpenAI-shape `assistant.tool_calls[].id` ↔ `role:tool.tool_call_id`) previously rendered through the legacy renderer after a reload or session switch, so a reloaded turn could look different from how it appeared live. Unambiguous, complete ID-linked turns now hydrate into the unified Assistant Turn Anchor before render, matching the live presentation exactly while failing closed to the existing renderer for any richer or ambiguous case. Thanks @franksong2702. (#6222, #6220)
|
||||
|
||||
- **New chat: you can now hide the entire welcome panel, not just the suggestions.** A new opt-in Settings → Preferences toggle, "Hide new-chat welcome panel," hides the whole empty-state block (logo, heading, subtitle, and suggestion buttons) on an empty new conversation while keeping the composer available and focused. It extends the existing "hide new-chat suggestions" preference with the same persisted, hot-applied pattern; default is unchanged (panel shown). Thanks @vaidu-ai. (#6183, #6160)
|
||||
|
||||
- **Workspace Artifacts pane: long filenames are fully readable and long parent directories stay inside the drawer.** A long artifact filename used to be ellipsized (so you couldn't tell files apart), and an unusually long immediate-parent directory could expand the Artifacts drawer horizontally and bleed past its edge. The list now shows the filename first with parent-preserving path truncation, and the directory tail is width-capped (`min-width: 0` + `overflow: hidden` + `text-overflow: ellipsis`) so it clips inside the card instead of blowing out the drawer. Adds behavioral/locale coverage for the artifact path lifecycle. Thanks @rodboev. (#6078, #6067)
|
||||
|
||||
- **The Kanban "New task" modal is reachable on phones again.** On a narrow viewport, a tall create-task modal was vertically centered, pushing its top (the title and first input) above the top of the screen with no way to scroll back to it. The mobile overlay now uses `align-items: safe center` and `overflow-y: auto`, so an overflowing modal keeps its top on-screen and scrolls as needed; desktop and wider layouts are unchanged. Thanks @jpalazz2. (#6301)
|
||||
|
||||
- **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023)
|
||||
|
||||
- **The consolidated Kanban board now fills the panel's vertical space.** In consolidated view (a single board, not split into per-profile lanes), the columns were previously clamped to a short fixed height, leaving a large empty gap below the board. The consolidated board now grows to the full panel height and scrolls as one tall surface; the per-profile lane view keeps its existing height clamp and contained scrolling. CSS-only. Thanks @jpalazz2. (#6308)
|
||||
|
||||
- **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351)
|
||||
|
||||
- **Completed image tool-results are compacted from persisted history, cutting long-session memory and save/load cost.** After a turn finishes, base64 image parts in persisted *tool* results are replaced with a small `[screenshot]` text placeholder, so JSON sidecar saves/loads and session API responses no longer scale with image bytes (the active model still gets the full image while the tool call is running). The transform preserves every non-image part in place (text, documents, provider-specific parts) without flattening or reordering, only touches `role: tool` messages (user attachments are untouched), runs on all live and settled persistence writebacks, and is idempotent. Thanks @sjungwon03. (#6315, #1217)
|
||||
|
||||
- **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311)
|
||||
|
||||
- **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853)
|
||||
|
||||
- **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291)
|
||||
|
||||
- **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400)
|
||||
|
||||
- **Kanban board columns are now scrollable on mobile.** On a phone, a touch-drag inside a Kanban column was trapped in the column's nested scroller, so you couldn't scroll the board. The mobile layout now lets the column overscroll chain to the page/section, restoring natural scrolling; desktop and the pull-to-refresh behavior are unchanged. Thanks @jpalazz2. (#6306)
|
||||
|
||||
- **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400)
|
||||
|
||||
- **Content search no longer evicts your open sessions from the cache.** A content search scans many sessions, and each scan used to promote/cold-load sessions into the in-memory LRU — evicting the sessions you actually have open. Search now reads through a scan-only accessor that keeps the same disk-freshness, journal-recovery, and state.db reconciliation (so recent content stays searchable) but doesn't disturb the working-set cache. Thanks @ai-ag2026. (#6084, #6083)
|
||||
|
||||
- **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836)
|
||||
|
||||
- **A turn that ends in an error no longer shows impossible "still running" tool work or a wrong duration.** On a terminal error, the settled (and reloaded) Worklog now freezes the turn's duration before cleanup and seals any still-projected tool rows as done, so a settled/reloaded transcript can't disagree with the terminal state. Successful and cancel paths are unchanged. Thanks @webtecnica. (#6323, #6309)
|
||||
|
||||
- **Terminal-error Worklog settlement is hardened further (follow-up to the fix above).** The inline turn-duration freeze is consolidated into a single `_terminal_turn_duration()` helper that returns *no* duration (rather than a manufactured `0s`) for a missing, non-finite, negative, or otherwise invalid timer origin — including a wildly out-of-range start value — so an invalid origin can never abort the terminal-error settlement path before the error message is persisted. The still-projected-tool-row sealing is now applied identically on both the frontend and the backend anchor-scene projector (a stream-owned running row without a settled assistant index seals to completed), and transcript-owned historical rows are explicitly left untouched. Thanks @franksong2702. (#6312, #6309)
|
||||
|
||||
- **Docker: experimental builds are now published to ghcr.** The image release workflow previously fired only on stable `v*` tags, so Docker/Portainer users couldn't track the experimental channel at all. It now also builds on `exp-v*` tags, publishing a floating `:experimental` tag (and marking the GitHub release as a prerelease) while the stable `:latest` / `v*` path is unchanged. Thanks @webtecnica. (#6329, #6298)
|
||||
|
||||
- **A gateway command-approval card with no `approval_id` is now respondable.** When a gateway `approval.request` omits both `approval_id` and the legacy `id`, WebUI now synthesizes a non-empty `uuid4().hex` id instead of leaving it blank, so the approval card can be answered (matching the `#527` stable-uuid design). The downstream respond path resolves by `run_id` independently, so the synthetic id causes no mismatch; a provided or legacy id is preserved verbatim. Thanks @webtecnica. (#6168, #6008)
|
||||
|
||||
@@ -373,7 +373,7 @@ Full list of environment variables:
|
||||
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
|
||||
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
|
||||
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
|
||||
| `HERMES_WEBUI_SESSIONS_MAX` | `300` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions — an evicted session lazily reloads from its JSON sidecar on next access |
|
||||
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access |
|
||||
|
||||
Extension deployments can inspect sanitized, authenticated diagnostics at `GET /api/extensions/status`; see [WebUI Extensions](docs/EXTENSIONS.md#diagnostics).
|
||||
|
||||
|
||||
+22
-9
@@ -93,18 +93,24 @@ that breaks the page for everyone).
|
||||
|
||||
## Public conversation lifecycle gate
|
||||
|
||||
`tests/browser_conversation_lifecycle.py` adds the first public, deterministic
|
||||
chat golden path. It drives the real composer and real WebUI server in Chromium,
|
||||
while a localhost-only fixture supplies reasoning, tool, and final-answer events
|
||||
through the existing Hermes Gateway Runs API. The gate asserts semantic activity
|
||||
during live streaming, after settlement, and after a hard reload, including
|
||||
`tests/browser_conversation_lifecycle.py` adds a public deterministic
|
||||
multi-row lifecycle gate. It drives the real composer and real WebUI server in Chromium,
|
||||
while a localhost-only fixture supplies reasoning, tool, process, and final/error
|
||||
events through the existing Hermes Gateway Runs API. The gate now covers both
|
||||
normal and terminal-error proof-matrix rows, asserting semantic activity during
|
||||
live streaming, after settlement, and after hard reload, including
|
||||
transcript-backed `activity_scene_v1` persistence and zero unexpected browser
|
||||
errors. It uses isolated temporary state and no provider credentials.
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt playwright
|
||||
python -m playwright install --with-deps chromium
|
||||
|
||||
# Normal-path deterministic conversation lifecycle gate.
|
||||
python tests/browser_conversation_lifecycle.py
|
||||
|
||||
# Terminal-error lifecycle gate (new row in the proof matrix).
|
||||
LIFECYCLE_SCENARIO=terminal-error python tests/browser_conversation_lifecycle.py
|
||||
```
|
||||
|
||||
To certify that the gate catches its target failure, the test owns an opt-in
|
||||
@@ -114,12 +120,19 @@ must fail at the hard-reload boundary:
|
||||
```bash
|
||||
LIFECYCLE_TEST_BITE=drop-anchor-persistence \
|
||||
python tests/browser_conversation_lifecycle.py
|
||||
|
||||
# Terminal-state-specific mutation bite: remove terminal row from persisted scene
|
||||
# so hard reload cannot recover terminal status.
|
||||
LIFECYCLE_SCENARIO=terminal-error \
|
||||
LIFECYCLE_TEST_BITE=drop-terminal-anchor-row \
|
||||
python tests/browser_conversation_lifecycle.py
|
||||
```
|
||||
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs this test
|
||||
without blocking merges while the public matrix is being established. The
|
||||
maintainer's private QA harness remains broader; later public slices will add
|
||||
session switching, reconnect/replay, cancellation, compression, and recovery.
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs both current
|
||||
proof rows (`normal` and `terminal-error`) and stays non-blocking while the public
|
||||
matrix expands to additional behavior rows. The maintainer's private QA harness
|
||||
remains broader; later public slices will add session switching, reconnect/replay,
|
||||
cancellation, compression, and recovery.
|
||||
|
||||
|
||||
`tests/test_static_js_runtime_lint.py` runs this automatically when eslint is present
|
||||
|
||||
+90
-19
@@ -484,6 +484,25 @@ def get_config() -> dict:
|
||||
return _cfg_cache
|
||||
|
||||
|
||||
def get_config_snapshot() -> dict:
|
||||
"""Return a request-owned config snapshot captured under the cache lock."""
|
||||
with _cfg_lock:
|
||||
config_path = _get_config_path()
|
||||
try:
|
||||
current_mtime = config_path.stat().st_mtime
|
||||
except OSError:
|
||||
current_mtime = 0.0
|
||||
path_changed = _cfg_path != config_path
|
||||
mtime_stale = current_mtime != _cfg_mtime
|
||||
if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()):
|
||||
_refresh_config_cache(config_path)
|
||||
try:
|
||||
active_cfg = cfg if cfg is not _cfg_cache else _cfg_cache
|
||||
except NameError:
|
||||
active_cfg = _cfg_cache
|
||||
return copy.deepcopy(active_cfg)
|
||||
|
||||
|
||||
def get_webui_session_save_mode(config_data: dict | None = None) -> str:
|
||||
"""Return the validated first-turn session persistence mode.
|
||||
|
||||
@@ -761,7 +780,8 @@ def _save_yaml_config_file(config_path: Path, config_data: dict) -> None:
|
||||
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
_paths._atomic_write_text(
|
||||
config_path,
|
||||
_yaml.safe_dump(_config_for_yaml_save(config_data), sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -1033,6 +1053,10 @@ MIME_MAP = {
|
||||
".m4v": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogv": "video/ogg",
|
||||
# TypeScript source files — served as text/plain to avoid XSS from
|
||||
# same-origin inline execution in _handle_file_raw.
|
||||
".ts": "text/plain",
|
||||
".tsx": "text/plain",
|
||||
}
|
||||
|
||||
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
|
||||
@@ -2957,7 +2981,13 @@ def resolve_model_provider(model_id: str, *, explicitly_picked: bool = False) ->
|
||||
# branch (#3872).
|
||||
_cp_lower_cross = (config_provider or "").strip().lower()
|
||||
_is_custom_cross = _cp_lower_cross == "custom" or _cp_lower_cross.startswith("custom:")
|
||||
if prefix in _PROVIDER_MODELS and prefix != config_provider and not _is_custom_cross:
|
||||
_canon_prefix = _canonicalise_provider_id(prefix)
|
||||
_canon_config_provider = _canonicalise_provider_id(config_provider)
|
||||
if (
|
||||
_canon_prefix in _PROVIDER_MODELS
|
||||
and _canon_prefix != _canon_config_provider
|
||||
and not _is_custom_cross
|
||||
):
|
||||
return model_id, "openrouter", None
|
||||
|
||||
return model_id, config_provider, config_base_url
|
||||
@@ -3866,6 +3896,33 @@ def resolve_model_reasoning_efforts(
|
||||
return filtered
|
||||
|
||||
|
||||
def _configured_reasoning_effort_lists(provider_entry, model_id: str) -> list:
|
||||
"""Return model-level then provider-level effort lists from config."""
|
||||
if not isinstance(provider_entry, dict):
|
||||
return []
|
||||
|
||||
configured_lists = []
|
||||
models = provider_entry.get("models")
|
||||
if isinstance(models, dict):
|
||||
model_key = str(model_id or "").strip().lower()
|
||||
model_entry = models.get(model_id)
|
||||
if not isinstance(model_entry, dict) and model_key:
|
||||
model_entry = next(
|
||||
(
|
||||
metadata
|
||||
for configured_id, metadata in models.items()
|
||||
if str(configured_id).strip().lower() == model_key
|
||||
and isinstance(metadata, dict)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if isinstance(model_entry, dict):
|
||||
configured_lists.append(model_entry.get("reasoning_efforts"))
|
||||
|
||||
configured_lists.append(provider_entry.get("reasoning_efforts"))
|
||||
return configured_lists
|
||||
|
||||
|
||||
def _resolve_model_reasoning_efforts_impl(
|
||||
model_id: str | None = None,
|
||||
provider_id: str | None = None,
|
||||
@@ -3899,29 +3956,32 @@ def _resolve_model_reasoning_efforts_impl(
|
||||
if _nested_route_reasoning_denied(hinted_model):
|
||||
return []
|
||||
|
||||
# 0. Provider config: providers.<name>.reasoning_efforts or named
|
||||
# custom_providers[].reasoning_efforts. When the user has explicitly listed
|
||||
# valid efforts for a provider, return that list directly — no heuristics,
|
||||
# no models.dev lookup.
|
||||
# Only short-circuits when the filtered list is non-empty; an all-invalid
|
||||
# list (e.g. typos) falls through to heuristics instead of hiding reasoning.
|
||||
_re_list = None
|
||||
# 0. Model/provider config: a models.<model>.reasoning_efforts list takes
|
||||
# precedence over its provider-level reasoning_efforts list. Explicit valid
|
||||
# config is authoritative — no heuristics or models.dev lookup. Invalid or
|
||||
# empty model metadata falls through to the provider list, then heuristics.
|
||||
_re_lists = []
|
||||
try:
|
||||
if provider and provider.startswith("custom:"):
|
||||
for _entry in _custom_provider_entries():
|
||||
if _custom_provider_slug_from_name(_entry.get("name")) == provider:
|
||||
_re_list = _entry.get("reasoning_efforts")
|
||||
_re_lists = _configured_reasoning_effort_lists(
|
||||
_entry, hinted_model
|
||||
)
|
||||
break
|
||||
elif provider:
|
||||
_prov_entry = (cfg.get("providers") or {}).get(provider, {})
|
||||
if isinstance(_prov_entry, dict):
|
||||
_re_list = _prov_entry.get("reasoning_efforts")
|
||||
if isinstance(_re_list, list) and _re_list:
|
||||
_filtered = [str(x).strip().lower() for x in _re_list
|
||||
if str(x).strip().lower() in {*VALID_REASONING_EFFORTS, "none"}]
|
||||
_filtered = list(dict.fromkeys(_filtered))
|
||||
if _filtered:
|
||||
return _filtered
|
||||
_re_lists = _configured_reasoning_effort_lists(
|
||||
_prov_entry, hinted_model
|
||||
)
|
||||
for _re_list in _re_lists:
|
||||
if isinstance(_re_list, list) and _re_list:
|
||||
_filtered = [str(x).strip().lower() for x in _re_list
|
||||
if str(x).strip().lower() in {*VALID_REASONING_EFFORTS, "none"}]
|
||||
_filtered = list(dict.fromkeys(_filtered))
|
||||
if _filtered:
|
||||
return _filtered
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -8483,16 +8543,18 @@ _INDEX_HTML_PATH = get_index_html_path()
|
||||
|
||||
# ── Thread synchronisation ───────────────────────────────────────────────────
|
||||
LOCK = threading.Lock()
|
||||
# Max compact Session objects held in the in-memory LRU (issue #3506, #4765).
|
||||
# Max compact Session objects held in the in-memory LRU (issue #3506, #4765, #6351).
|
||||
# Lighter than the agent cache (no live agent runtime), but still bounded so a
|
||||
# long-running self-hosted install cannot accumulate every session it ever
|
||||
# touched in RAM and eventually segfault (the #4765/#2233/#4633 crash cluster).
|
||||
# The shipped default is tuned for the common single-user install; larger
|
||||
# deployments can keep raising it through config.yaml or the legacy env fallback.
|
||||
#
|
||||
# Precedence for the effective cap is resolved by get_sessions_cache_max():
|
||||
# 1. config.yaml webui.sessions_cache_max (preferred, no new env var)
|
||||
# 2. HERMES_WEBUI_SESSIONS_MAX env var (legacy operator override)
|
||||
# 3. DEFAULT_SESSIONS_CACHE_MAX (sane bounded default)
|
||||
DEFAULT_SESSIONS_CACHE_MAX = 300
|
||||
DEFAULT_SESSIONS_CACHE_MAX = 100
|
||||
SESSIONS_MAX = _env_int("HERMES_WEBUI_SESSIONS_MAX", DEFAULT_SESSIONS_CACHE_MAX)
|
||||
|
||||
|
||||
@@ -8835,6 +8897,7 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
|
||||
caps = {
|
||||
"approval_events": False,
|
||||
"run_approval_response": False,
|
||||
"approval_identity_v1": False,
|
||||
"capabilities_reachable": False,
|
||||
"probe_error": None,
|
||||
"fetched_at": 0.0,
|
||||
@@ -8852,6 +8915,7 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
|
||||
features = {}
|
||||
caps["approval_events"] = bool(features.get("approval_events"))
|
||||
caps["run_approval_response"] = bool(features.get("run_approval_response"))
|
||||
caps["approval_identity_v1"] = bool(features.get("approval_identity_v1"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
caps["capabilities_reachable"] = True
|
||||
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
|
||||
@@ -8891,6 +8955,11 @@ def gateway_supports_approval(base_url: str, api_key: str = "") -> bool:
|
||||
return bool(caps.get("approval_events") and caps.get("run_approval_response"))
|
||||
|
||||
|
||||
def gateway_supports_approval_identity_v1(base_url: str, api_key: str = "") -> bool:
|
||||
"""True only when Gateway advertises authoritative approval identities."""
|
||||
return bool(get_gateway_caps(base_url, api_key).get("approval_identity_v1"))
|
||||
|
||||
|
||||
def invalidate_gateway_caps(base_url: str | None = None) -> None:
|
||||
"""Evict capability cache for base_url, or all entries when base_url is None."""
|
||||
with _GATEWAY_CAPS_LOCK:
|
||||
@@ -9118,6 +9187,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel
|
||||
"show_busy_placeholder_hint": False, # opt-in busy composer placeholder hint
|
||||
"hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons
|
||||
"hide_empty_state_panel": False, # hide the complete new-chat welcome panel
|
||||
"new_chat_on_workspace_switch": False, # #5473 opt-in: switching to a DIFFERENT workspace starts a new chat (leaving the current conversation on its original workspace) instead of mutating the current session's workspace in place. Default OFF preserves the shipped in-place-switch behavior.
|
||||
"virtualize_transcript": False, # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings).
|
||||
"virtualize_transcript_optin": False, # #4343 migration marker: True only once the user explicitly enables virtualize_transcript AFTER the default-off flip. A stored virtualize_transcript=True WITHOUT this marker is a stale pre-flip value and is reset to False on load (force-off-for-everyone migration).
|
||||
@@ -9440,6 +9510,7 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"show_conversation_outline",
|
||||
"show_busy_placeholder_hint",
|
||||
"hide_empty_state_suggestions",
|
||||
"hide_empty_state_panel",
|
||||
"new_chat_on_workspace_switch",
|
||||
"virtualize_transcript",
|
||||
"virtualize_transcript_optin",
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Extension sidecar proxy authentication (token-v1).
|
||||
|
||||
A loopback-sidecar extension runs a stdlib HTTP server on 127.0.0.1:<port>. The
|
||||
browser reaches it ONLY through core's consent-gated proxy
|
||||
(``/api/extensions/{id}/sidecar/*``). But the loopback port is reachable by any
|
||||
local process, and the proxy strips every inbound credential before forwarding
|
||||
(see ``_extension_sidecar_proxy_request_headers`` in ``api/routes.py``), so the
|
||||
sidecar cannot tell a proxied request from a direct one.
|
||||
|
||||
This module mints a per-extension shared secret that core injects on every
|
||||
forwarded request (header ``X-Hermes-Sidecar-Token``) and the sidecar validates.
|
||||
It converts "anyone who can send a loopback TCP packet" (other-UID users, host
|
||||
containers, sandboxed network-only processes) into "processes that can read the
|
||||
user's state dir" — the same protection level core's own signing key already has
|
||||
(``.pbkdf2_key`` / ``.signing_key`` are 0600 files in the same directory).
|
||||
|
||||
SCOPE (be honest — this is documented in the contract too): the token does NOT
|
||||
defend against arbitrary same-UID code. A same-user process can read the token,
|
||||
read core's signing key, or just run the sidecar's underlying tool directly. No
|
||||
mechanism available here (token, HMAC, nonce, UDS with 0600) changes that.
|
||||
|
||||
Design notes (from the reviewed design doc, §9.2):
|
||||
* Per-extension token file under ``STATE_DIR/sidecar-auth/<ext-id>.token``.
|
||||
* Unlike ``auth._load_key``, we NEVER return an in-memory-only token: if the
|
||||
file cannot be persisted+re-read, we report "unavailable" so core fails
|
||||
closed (503) rather than injecting a secret no sidecar can ever read.
|
||||
* The token is re-read from disk per request (fingerprint-cached on
|
||||
inode/mtime/ctime/size) so rotation / deletion takes effect with no restart,
|
||||
and a stale cached token can't keep validating after the file changes.
|
||||
* Cross-process mint writes a unique temporary file, then publishes it with
|
||||
an atomic ``os.link`` create-or-fail operation. Concurrent first-mints
|
||||
converge on a single winning file; losers re-read the winner rather than
|
||||
clobbering it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
# Keep this grammar identical to ``_EXTENSION_ID_RE`` in ``api/extensions.py`` —
|
||||
# a narrower pattern here silently 503s legally-named extensions (uppercase,
|
||||
# dots, up to 128 chars). The pattern is filesystem-safe as a filename: no path
|
||||
# separators, cannot start with a dot, so no dotfile / ".."-prefix tricks.
|
||||
_EXT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
# Injected/validated tokens are exactly secrets.token_urlsafe output: url-safe
|
||||
# base64 alphabet. Reject anything else (bounds length, and stops a malformed
|
||||
# token file's contents from reaching a header where a ValueError could echo it).
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
||||
|
||||
_TOKEN_DIR_NAME = "sidecar-auth"
|
||||
_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) -> ~43 url-safe chars
|
||||
|
||||
# Per-request read cache: ext_id -> (token, fingerprint). Guarded by _LOCK.
|
||||
_LOCK = threading.Lock()
|
||||
_CACHE: Dict[str, Tuple[str, Tuple]] = {}
|
||||
|
||||
|
||||
def _token_dir() -> Path:
|
||||
# Resolve STATE_DIR dynamically (mirrors ``_extension_state_dir`` in
|
||||
# api/extensions.py) so tests and relocated installs see the current dir
|
||||
# rather than the value cached at import time.
|
||||
from api.config import STATE_DIR
|
||||
return STATE_DIR / _TOKEN_DIR_NAME
|
||||
|
||||
|
||||
def _token_path(ext_id: str) -> Path:
|
||||
return _token_dir() / f"{ext_id}.token"
|
||||
|
||||
|
||||
def _valid_ext_id(ext_id: object) -> bool:
|
||||
# Use fullmatch (not match) so a trailing newline can't slip through: in
|
||||
# Python's ``re`` a ``$`` anchor also matches just before a final ``\n``,
|
||||
# so ``match`` would accept ``"templates\n"`` and _token_path would build a
|
||||
# filename containing a literal newline. fullmatch anchors truly to the end.
|
||||
# Validate the exact value that _token_path() uses. Trimming only for the
|
||||
# check would accept one ID but construct a filename from a different one.
|
||||
return isinstance(ext_id, str) and bool(_EXT_ID_RE.fullmatch(ext_id))
|
||||
|
||||
|
||||
def _fingerprint(path: Path) -> Optional[Tuple]:
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return (st.st_ino, st.st_dev, st.st_mtime_ns, st.st_ctime_ns, st.st_size)
|
||||
|
||||
|
||||
def _read_token_file(path: Path) -> Optional[str]:
|
||||
"""Return the token text iff it is present and well-formed, else None
|
||||
(fail closed). Only a valid token-v1 shape is ever returned, so malformed
|
||||
file contents can never reach a header. Thin wrapper over the stable reader."""
|
||||
tok, _fp = _stable_read(path)
|
||||
return tok
|
||||
|
||||
|
||||
def _stable_read(path: Path) -> Tuple[Optional[str], Optional[Tuple]]:
|
||||
"""Read the token together with a fingerprint that is verified to bracket the
|
||||
read (fingerprint before AND after; retry on any mid-read change). Guarantees
|
||||
the returned (token, fingerprint) pair reflects a single consistent on-disk
|
||||
state — never new content under an old stat key, and never a value that
|
||||
changed underneath us. Returns (None, None) when absent/malformed/racing."""
|
||||
for _ in range(4): # bounded retry; a file being rotated settles fast
|
||||
fp_before = _fingerprint(path)
|
||||
if fp_before is None:
|
||||
return (None, None)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return (None, None)
|
||||
fp_after = _fingerprint(path)
|
||||
if fp_after != fp_before:
|
||||
continue # changed mid-read — retry for a consistent snapshot
|
||||
tok = raw.strip()
|
||||
if not tok or not _TOKEN_RE.match(tok):
|
||||
return (None, None)
|
||||
return (tok, fp_before)
|
||||
return (None, None) # never settled — fail closed
|
||||
|
||||
|
||||
def ensure_token(ext_id: str) -> Optional[str]:
|
||||
"""Get-or-create the per-extension token, returning it ONLY if it is durably
|
||||
persisted and re-readable from disk. Returns None if it can't be persisted
|
||||
(caller must then treat the sidecar proxy as unavailable / 503) — we never
|
||||
hand back an ephemeral token a sidecar could never read.
|
||||
|
||||
Idempotent + atomic across processes: the token is written to a unique temp
|
||||
file, then published with an atomic hard-link create-or-fail operation, so
|
||||
the final path is only ever visible fully written and is never clobbered.
|
||||
One concurrent writer wins; every loser reads that persisted winner.
|
||||
"""
|
||||
if not _valid_ext_id(ext_id):
|
||||
return None
|
||||
path = _token_path(ext_id)
|
||||
|
||||
existing = _read_token_file(path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
with _LOCK:
|
||||
existing = _read_token_file(path)
|
||||
if existing is not None:
|
||||
return existing
|
||||
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
tmp: Optional[Path] = None
|
||||
try:
|
||||
d = _token_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(d, 0o700)
|
||||
except OSError:
|
||||
pass # best-effort dir hardening; file perms are the real guard
|
||||
# Write to a unique temp, fsync, then publish with atomic
|
||||
# create-or-fail (os.link, the repo's TOCTOU-safe idiom — see
|
||||
# session_recovery.py:627). Unlike os.replace, link does NOT clobber:
|
||||
# if another process already published, ours fails, we drop our temp,
|
||||
# and everyone reads the single winning file. This closes both the
|
||||
# empty-file-exposure race AND the cross-process clobber race.
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, token.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.link(str(tmp), str(path))
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except FileExistsError:
|
||||
pass # another writer won; we return the winner via _stable_read below
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
tmp = None
|
||||
except OSError:
|
||||
# Persistence failed → report unavailable. Do NOT return the
|
||||
# in-memory token (the _load_key trap: core would inject a secret the
|
||||
# sidecar can never read → permanent 401 that looks like a mismatch).
|
||||
if tmp is not None:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
# Return whatever is durably on disk now — under a concurrent mint this
|
||||
# may be another writer's token, which is correct (all readers converge
|
||||
# on the winning file). Only a persisted, well-formed token is returned.
|
||||
persisted, fp = _stable_read(path)
|
||||
if persisted is not None and fp is not None:
|
||||
_CACHE[ext_id] = (persisted, fp)
|
||||
return persisted
|
||||
|
||||
|
||||
def current_token(ext_id: str) -> Optional[str]:
|
||||
"""Return the current on-disk token for validation/injection, re-reading the
|
||||
file when its fingerprint changed since last read — so rotation + deletion
|
||||
take effect immediately and a stale cached token stops validating. Does NOT
|
||||
mint. Returns None when no token exists (fail closed)."""
|
||||
if not _valid_ext_id(ext_id):
|
||||
return None
|
||||
path = _token_path(ext_id)
|
||||
fp = _fingerprint(path)
|
||||
if fp is None:
|
||||
with _LOCK:
|
||||
_CACHE.pop(ext_id, None)
|
||||
return None
|
||||
with _LOCK:
|
||||
cached = _CACHE.get(ext_id)
|
||||
if cached is not None and cached[1] == fp:
|
||||
return cached[0]
|
||||
tok, tok_fp = _stable_read(path) # bracketed read: content matches tok_fp
|
||||
with _LOCK:
|
||||
if tok is None or tok_fp is None:
|
||||
_CACHE.pop(ext_id, None)
|
||||
else:
|
||||
_CACHE[ext_id] = (tok, tok_fp)
|
||||
return tok
|
||||
|
||||
|
||||
def reset_token(ext_id: str) -> Optional[str]:
|
||||
"""Rotate: delete then re-mint. Returns the new token or None on failure.
|
||||
(Recovery entry point; also usable by a future 'reset token' action.)"""
|
||||
if not _valid_ext_id(ext_id):
|
||||
return None
|
||||
with _LOCK:
|
||||
_CACHE.pop(ext_id, None)
|
||||
try:
|
||||
_token_path(ext_id).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
return None
|
||||
return ensure_token(ext_id)
|
||||
+110
-2
@@ -898,6 +898,21 @@ def _sidecar_from_manifest_entry(
|
||||
return None
|
||||
else:
|
||||
health_path = _DEFAULT_SIDECAR_HEALTH_PATH
|
||||
# proxy_auth negotiation (token-v1). Absent = explicit legacy (unauthenticated
|
||||
# proxy→sidecar, unchanged behavior for any pre-existing declaration). token-v1
|
||||
# = core mints+injects a per-extension token and the sidecar validates it.
|
||||
# Any unknown value fails closed (reject the sidecar) so a typo can never
|
||||
# silently downgrade to unauthenticated.
|
||||
raw_proxy_auth = raw.get("proxy_auth")
|
||||
if raw_proxy_auth is None:
|
||||
proxy_auth = "legacy"
|
||||
elif raw_proxy_auth in ("legacy", "token-v1"):
|
||||
proxy_auth = str(raw_proxy_auth)
|
||||
else:
|
||||
_add_diagnostic_warning(
|
||||
diagnostics, "sidecar_proxy_auth_unsupported", _SIDECAR_WARNING_SOURCE
|
||||
)
|
||||
return None
|
||||
sidecar_id = _manifest_entry_text(entry, "id")
|
||||
name = _manifest_entry_text(entry, "name")
|
||||
return {
|
||||
@@ -907,6 +922,7 @@ def _sidecar_from_manifest_entry(
|
||||
"origin": origin,
|
||||
"health_path": health_path,
|
||||
"health_url": f"{origin}{health_path}",
|
||||
"proxy_auth": proxy_auth,
|
||||
}
|
||||
|
||||
|
||||
@@ -1159,16 +1175,37 @@ def _sidecar_proxy_public_status(
|
||||
approved_origin: Optional[str],
|
||||
*,
|
||||
available: bool,
|
||||
proxy_auth: str = "legacy",
|
||||
) -> Dict[str, Any]:
|
||||
consented = bool(available and approved_origin == origin)
|
||||
origin_changed = bool(available and approved_origin and approved_origin != origin)
|
||||
return {
|
||||
payload: Dict[str, Any] = {
|
||||
"available": available,
|
||||
"consented": consented,
|
||||
"consent_required": bool(available and not consented),
|
||||
"path": _extension_sidecar_proxy_path(extension_id),
|
||||
"origin_changed": origin_changed,
|
||||
}
|
||||
# proxy_auth / posture are token-v1-only fields. Legacy sidecars keep the
|
||||
# original payload shape byte-for-byte (the public status contract tests pin
|
||||
# it), so a token-v1 negotiation field never leaks onto a legacy record.
|
||||
# (Frank #6331 blocker 2 — fields were previously emitted on every record and
|
||||
# broke the legacy exact-shape contract on shard 4.)
|
||||
if proxy_auth == "token-v1":
|
||||
# token-v1 posture (§9.1). "protected" = WebUI auth ON (the only posture
|
||||
# in which token-v1 proxying is permitted; auth-off is now fail-closed at
|
||||
# both consent and resolution, so "local_unprotected" signals the panel
|
||||
# to tell the operator to enable authentication before the proxy will work).
|
||||
posture = None
|
||||
if available:
|
||||
try:
|
||||
from api.auth import is_auth_enabled
|
||||
posture = "protected" if is_auth_enabled() else "local_unprotected"
|
||||
except Exception:
|
||||
posture = None
|
||||
payload["proxy_auth"] = proxy_auth
|
||||
payload["posture"] = posture
|
||||
return payload
|
||||
|
||||
|
||||
def _extension_sidecar_records(
|
||||
@@ -1216,11 +1253,21 @@ def _extension_sidecar_records(
|
||||
sidecar["origin"],
|
||||
item.get("approved_origin"),
|
||||
available=available,
|
||||
proxy_auth=sidecar.get("proxy_auth", "legacy"),
|
||||
)
|
||||
item["duplicate_id"] = id_counts.get(ext_id, 0) > 1
|
||||
item["proxy"] = proxy
|
||||
if len(records) < _MAX_URL_LIST:
|
||||
records.append({**sidecar, "proxy": proxy})
|
||||
# Build the PUBLIC sidecar record explicitly. The internal `sidecar`
|
||||
# dict carries a top-level `proxy_auth` for the consent/resolution
|
||||
# logic, but the public status contract only exposes proxy_auth (and
|
||||
# posture) INSIDE `proxy`, and only for token-v1 records — legacy
|
||||
# sidecars keep their original public shape byte-for-byte. Spreading
|
||||
# `**sidecar` leaked a top-level `proxy_auth: legacy` and broke the
|
||||
# exact-shape contract tests (Frank #6331 blocker 2).
|
||||
public_record = {k: v for k, v in sidecar.items() if k != "proxy_auth"}
|
||||
public_record["proxy"] = proxy
|
||||
records.append(public_record)
|
||||
else:
|
||||
_add_diagnostic_warning(diagnostics, "sidecar_list_truncated", _SIDECAR_WARNING_SOURCE)
|
||||
break
|
||||
@@ -1564,6 +1611,32 @@ def set_extension_sidecar_proxy_consent(extension_id: object, approved: object)
|
||||
if sidecar is None or proxy.get("available") is not True:
|
||||
raise ExtensionSidecarProxyError("Extension sidecar proxy is unavailable", status=409)
|
||||
consent_map[ext_id] = sidecar["origin"]
|
||||
# For token-v1, mint the per-extension token NOW and fail the consent
|
||||
# if it can't be persisted+read — otherwise we'd persist consent and
|
||||
# then 503 on every request (a mint failure must surface at grant
|
||||
# time, not as a mysterious later error).
|
||||
if sidecar.get("proxy_auth") == "token-v1":
|
||||
# token-v1 REQUIRES configured WebUI authentication. Without it,
|
||||
# this consent endpoint is itself unauthenticated, so any caller
|
||||
# that can reach the WebUI listener could self-grant consent and
|
||||
# then drive the token-bearing proxy (a forwarding oracle) — the
|
||||
# sidecar token file blocks direct port access but does NOT stop a
|
||||
# caller who simply asks core to inject it. Loopback origin is not
|
||||
# sufficient: another local UID can still reach the listener
|
||||
# without reading the 0600 token file. Fail closed regardless of
|
||||
# origin. (Frank #6331 blocker 1.)
|
||||
from api.auth import is_auth_enabled
|
||||
if not is_auth_enabled():
|
||||
raise ExtensionSidecarProxyError(
|
||||
"Sidecar token-v1 proxy requires WebUI authentication; "
|
||||
"set a password in Settings before granting consent.",
|
||||
status=403,
|
||||
)
|
||||
from api import extension_sidecar_auth as _sc_auth
|
||||
if not _sc_auth.ensure_token(ext_id):
|
||||
raise ExtensionSidecarProxyError(
|
||||
"Could not provision the sidecar auth token", status=503
|
||||
)
|
||||
else:
|
||||
consent_map.pop(ext_id, None)
|
||||
_write_extension_state(
|
||||
@@ -1625,11 +1698,46 @@ def resolve_extension_sidecar_proxy_target(
|
||||
upstream_url = f"{sidecar['origin']}{normalized_path}"
|
||||
if query:
|
||||
upstream_url = f"{upstream_url}?{query}"
|
||||
|
||||
# token-v1 auth (§9.1/§9.2). Legacy sidecars proxy unchanged; token-v1
|
||||
# sidecars get a per-extension secret injected, and only proxy when the
|
||||
# trust posture is sound.
|
||||
proxy_auth = sidecar.get("proxy_auth", "legacy")
|
||||
inject_token: Optional[str] = None
|
||||
if proxy_auth == "token-v1":
|
||||
from api.auth import is_auth_enabled
|
||||
from api import extension_sidecar_auth as _sc_auth
|
||||
|
||||
# token-v1 REQUIRES configured WebUI authentication — fail closed
|
||||
# regardless of the upstream origin (loopback included). The consent
|
||||
# endpoint and this resolution path are both unauthenticated when auth is
|
||||
# off, so a caller that can reach the WebUI listener could otherwise drive
|
||||
# a token-bearing proxy to the sidecar (a forwarding oracle). Loopback is
|
||||
# NOT a sufficient guard: another local UID can reach the listener without
|
||||
# ever reading the 0600 token file. (Frank #6331 blocker 1 — this
|
||||
# mirrors the consent-time check so a pre-existing consent granted while
|
||||
# auth was enabled cannot be exercised after auth is turned off.)
|
||||
if not is_auth_enabled():
|
||||
raise ExtensionSidecarProxyError(
|
||||
"Sidecar token-v1 proxy requires WebUI authentication; "
|
||||
"set a password in Settings.",
|
||||
status=403,
|
||||
)
|
||||
token = _sc_auth.current_token(ext_id) or _sc_auth.ensure_token(ext_id)
|
||||
if not token:
|
||||
# Token could not be persisted+read → fail closed rather than proxy
|
||||
# unauthenticated (never inject an ephemeral secret).
|
||||
raise ExtensionSidecarProxyError(
|
||||
"Sidecar proxy auth token is unavailable", status=503
|
||||
)
|
||||
inject_token = token
|
||||
return {
|
||||
"extension_id": ext_id,
|
||||
"origin": sidecar["origin"],
|
||||
"proxy_path": proxy["path"],
|
||||
"upstream_url": upstream_url,
|
||||
"proxy_auth": proxy_auth,
|
||||
"auth_token": inject_token,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+270
-105
@@ -8,10 +8,12 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from api.config import (
|
||||
AGENT_INSTANCES,
|
||||
CANCEL_FLAGS,
|
||||
PENDING_GOAL_CONTINUATION,
|
||||
STREAM_GOAL_RELATED,
|
||||
@@ -38,6 +40,110 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps stream_id -> gateway run_id for approval response relay.
|
||||
_STREAM_RUN_IDS: dict[str, str] = {}
|
||||
_STREAM_RUN_LIFECYCLE: dict[str, dict[str, Any]] = {}
|
||||
_STREAM_RUN_STARTING_CONDITION = threading.Condition()
|
||||
GATEWAY_RUN_ID_WAIT_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _mark_gateway_run_starting(stream_id: str) -> None:
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
_STREAM_RUN_LIFECYCLE[stream_id] = {
|
||||
"phase": "pending",
|
||||
"run_id": "",
|
||||
"waiters": 0,
|
||||
"owner_done": False,
|
||||
}
|
||||
|
||||
|
||||
def _publish_gateway_run_id(stream_id: str, run_id: str) -> None:
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
_STREAM_RUN_IDS[stream_id] = run_id
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id) or {}
|
||||
_STREAM_RUN_LIFECYCLE[stream_id] = {
|
||||
"phase": "ready",
|
||||
"run_id": run_id,
|
||||
"waiters": int(state.get("waiters") or 0),
|
||||
"owner_done": bool(state.get("owner_done")),
|
||||
}
|
||||
_STREAM_RUN_STARTING_CONDITION.notify_all()
|
||||
|
||||
|
||||
def _finish_gateway_run_starting(stream_id: str, *, result: str = "failed") -> None:
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id) or {}
|
||||
if str(state.get("phase") or "").strip().lower() == "ready":
|
||||
return
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
_STREAM_RUN_LIFECYCLE[stream_id] = {
|
||||
"phase": "fallback" if result == "fallback" else "failed",
|
||||
"run_id": "",
|
||||
"waiters": int(state.get("waiters") or 0),
|
||||
"owner_done": bool(state.get("owner_done")),
|
||||
}
|
||||
_STREAM_RUN_STARTING_CONDITION.notify_all()
|
||||
|
||||
|
||||
def _retire_gateway_run_starting_if_done(stream_id: str) -> bool:
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
|
||||
if not state:
|
||||
return False
|
||||
if int(state.get("waiters") or 0) > 0:
|
||||
return False
|
||||
if not bool(state.get("owner_done")):
|
||||
return False
|
||||
_STREAM_RUN_LIFECYCLE.pop(stream_id, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
return True
|
||||
|
||||
|
||||
def _clear_gateway_run_starting(stream_id: str) -> None:
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
|
||||
if state:
|
||||
state["owner_done"] = True
|
||||
_retire_gateway_run_starting_if_done(stream_id)
|
||||
_STREAM_RUN_STARTING_CONDITION.notify_all()
|
||||
|
||||
|
||||
def gateway_run_id_pending(stream_id: str) -> bool:
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
return str((_STREAM_RUN_LIFECYCLE.get(stream_id) or {}).get("phase") or "").strip().lower() == "pending"
|
||||
|
||||
|
||||
def wait_for_gateway_run_id(stream_id: str, timeout: float) -> tuple[bool, str | None]:
|
||||
deadline = time.monotonic() + max(0.0, float(timeout))
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
|
||||
if state:
|
||||
state["waiters"] = int(state.get("waiters") or 0) + 1
|
||||
try:
|
||||
while True:
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
|
||||
phase = str((state or {}).get("phase") or "").strip().lower()
|
||||
if phase == "fallback":
|
||||
return False, None
|
||||
if phase == "failed":
|
||||
return True, None
|
||||
run_id = str(_STREAM_RUN_IDS.get(stream_id) or "").strip()
|
||||
if phase == "ready":
|
||||
stored_run_id = str((state or {}).get("run_id") or "").strip()
|
||||
return True, run_id or stored_run_id or None
|
||||
if run_id:
|
||||
return True, run_id
|
||||
if not state:
|
||||
return False, None
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return True, None
|
||||
_STREAM_RUN_STARTING_CONDITION.wait(timeout=remaining)
|
||||
finally:
|
||||
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
|
||||
if state:
|
||||
waiters = max(0, int(state.get("waiters") or 0) - 1)
|
||||
state["waiters"] = waiters
|
||||
if _retire_gateway_run_starting_if_done(stream_id):
|
||||
_STREAM_RUN_STARTING_CONDITION.notify_all()
|
||||
|
||||
_WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND"
|
||||
_WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
|
||||
@@ -336,7 +442,8 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
|
||||
pattern_key = str(payload.get("pattern_key") or "").strip()
|
||||
args = payload.get("args") if isinstance(payload.get("args"), (list, dict)) else []
|
||||
run_id = str(payload.get("run_id") or "").strip()
|
||||
approval_id = str(payload.get("approval_id") or payload.get("id") or "").strip()
|
||||
raw_approval_id = str(payload.get("approval_id") or payload.get("id") or "").strip()
|
||||
approval_id = raw_approval_id
|
||||
if not approval_id:
|
||||
approval_id = uuid.uuid4().hex
|
||||
risk = str(payload.get("risk_level") or "high").strip()
|
||||
@@ -356,6 +463,7 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
|
||||
"risk_level": risk,
|
||||
"run_id": run_id,
|
||||
"approval_id": approval_id,
|
||||
"_gateway_raw_approval_id_present": bool(raw_approval_id),
|
||||
"choices": choices,
|
||||
"allow_permanent": bool(allow_permanent),
|
||||
}
|
||||
@@ -368,88 +476,92 @@ def _run_gateway_runs_api_streaming(
|
||||
attachments=None, cfg=None, session=None,
|
||||
):
|
||||
"""Submit via POST /v1/runs and relay SSE events including approval."""
|
||||
url_runs = f"{base_url.rstrip('/')}/v1/runs"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
message_content: Any = str(msg_text or "")
|
||||
if attachments:
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
try:
|
||||
url_runs = f"{base_url.rstrip('/')}/v1/runs"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
message_content: Any = str(msg_text or "")
|
||||
if attachments:
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
|
||||
except Exception:
|
||||
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
from api.streaming import _strip_oob_blocks
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
|
||||
except Exception:
|
||||
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
from api.streaming import _strip_oob_blocks
|
||||
|
||||
instructions_parts = []
|
||||
conversation_history = []
|
||||
for entry in getattr(session, "context_messages", None) or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
role = str(entry.get("role") or "").strip().lower()
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
content = entry.get("content")
|
||||
if content is not None:
|
||||
content = _strip_oob_blocks(content)
|
||||
instructions_parts = []
|
||||
conversation_history = []
|
||||
for entry in getattr(session, "context_messages", None) or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
role = str(entry.get("role") or "").strip().lower()
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
content = entry.get("content")
|
||||
if content is not None:
|
||||
content = _strip_oob_blocks(content)
|
||||
conversation_history.append({"role": role, "content": content})
|
||||
for entry in prefill_messages or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
role = str(entry.get("role") or "").strip().lower()
|
||||
content = entry.get("content")
|
||||
if role == "system":
|
||||
if isinstance(content, str) and content.strip():
|
||||
instructions_parts.append(content)
|
||||
elif content is not None:
|
||||
instructions_parts.append(str(content))
|
||||
continue
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
if content is not None:
|
||||
content = _strip_oob_blocks(content)
|
||||
conversation_history.append({"role": role, "content": content})
|
||||
for entry in prefill_messages or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
role = str(entry.get("role") or "").strip().lower()
|
||||
content = entry.get("content")
|
||||
if role == "system":
|
||||
if isinstance(content, str) and content.strip():
|
||||
instructions_parts.append(content)
|
||||
elif content is not None:
|
||||
instructions_parts.append(str(content))
|
||||
continue
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
if content is not None:
|
||||
content = _strip_oob_blocks(content)
|
||||
conversation_history.append({"role": role, "content": content})
|
||||
run_input = message_content
|
||||
if isinstance(run_input, list):
|
||||
run_input = [{"role": "user", "content": run_input}]
|
||||
run_body = {
|
||||
"model": model or "default",
|
||||
"input": run_input,
|
||||
**body_extras,
|
||||
"session_id": session_id,
|
||||
}
|
||||
if instructions_parts:
|
||||
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
|
||||
if conversation_history:
|
||||
run_body["conversation_history"] = conversation_history
|
||||
req = urllib.request.Request(
|
||||
url_runs,
|
||||
data=json.dumps(run_body).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
update_active_run(stream_id, phase="gateway-request")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
run_data = json.loads(resp.read(65536))
|
||||
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
|
||||
run_input = message_content
|
||||
if isinstance(run_input, list):
|
||||
run_input = [{"role": "user", "content": run_input}]
|
||||
run_body = {
|
||||
"model": model or "default",
|
||||
"input": run_input,
|
||||
**body_extras,
|
||||
"session_id": session_id,
|
||||
}
|
||||
if instructions_parts:
|
||||
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
|
||||
if conversation_history:
|
||||
run_body["conversation_history"] = conversation_history
|
||||
req = urllib.request.Request(
|
||||
url_runs,
|
||||
data=json.dumps(run_body).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
update_active_run(stream_id, phase="gateway-request")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
run_data = json.loads(resp.read(65536))
|
||||
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
|
||||
except Exception:
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
raise
|
||||
|
||||
_STREAM_RUN_IDS[stream_id] = run_id
|
||||
usage: dict = {}
|
||||
_publish_gateway_run_id(stream_id, run_id)
|
||||
|
||||
url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events"
|
||||
headers_sse = dict(headers)
|
||||
headers_sse["Accept"] = "text/event-stream"
|
||||
req_events = urllib.request.Request(url_events, headers=headers_sse, method="GET")
|
||||
final_text = ""
|
||||
usage: dict = {}
|
||||
sse_event = "message"
|
||||
with urllib.request.urlopen(req_events, timeout=_gateway_read_timeout_secs()) as resp:
|
||||
for raw_line in _iter_sse_lines_cancellable(resp, cancel_event):
|
||||
@@ -477,7 +589,11 @@ def _run_gateway_runs_api_streaming(
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if approval_data:
|
||||
approval_data["run_id"] = run_id
|
||||
put_gateway_event("approval", approval_data)
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
approval_data["_gateway_agent_identity_v1"] = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
from api.route_approvals import submit_gateway_pending_mirror
|
||||
head, total = submit_gateway_pending_mirror(session_id, approval_data)
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event in {"tool.started", "tool.completed", "reasoning.available"}:
|
||||
@@ -521,6 +637,8 @@ def _run_gateway_runs_api_streaming(
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event == "run.completed":
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=run_id)
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(str(payload["error"]))
|
||||
output = str(payload.get("output") or "")
|
||||
@@ -532,8 +650,12 @@ def _run_gateway_runs_api_streaming(
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event == "run.failed":
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=run_id)
|
||||
raise RuntimeError(str(payload.get("error") or "Gateway run failed"))
|
||||
if payload_event == "run.cancelled":
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=run_id)
|
||||
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
|
||||
return None, usage
|
||||
reasoning_delta = _gateway_sse_reasoning_delta(payload)
|
||||
@@ -551,6 +673,40 @@ def _run_gateway_runs_api_streaming(
|
||||
return final_text, usage
|
||||
|
||||
|
||||
def stop_gateway_run(run_id: str) -> bool:
|
||||
"""Request gateway interruption and report whether it was acknowledged."""
|
||||
run_id = str(run_id or "").strip()
|
||||
if not run_id:
|
||||
return False
|
||||
from api.config import get_config
|
||||
|
||||
cfg = get_config()
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/v1/runs/{urllib.parse.quote(run_id, safe='')}/stop",
|
||||
data=b"{}",
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
try:
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
with opener.open(req, timeout=10) as response:
|
||||
final_url = str(getattr(response, "geturl", lambda: req.full_url)() or "")
|
||||
status = int(getattr(response, "status", getattr(response, "code", 0)) or 0)
|
||||
return 200 <= status < 300 and final_url == req.full_url
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError):
|
||||
logger.debug("Gateway stop failed for run %s", run_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, model_provider, terminal_error):
|
||||
from api.streaming import (
|
||||
_classify_provider_error,
|
||||
@@ -558,6 +714,7 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
_provider_error_payload,
|
||||
_session_payload_with_full_messages,
|
||||
_snapshot_and_append_partial_on_error,
|
||||
_terminal_turn_duration,
|
||||
)
|
||||
|
||||
with _get_session_agent_lock(session_id):
|
||||
@@ -570,14 +727,7 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
error_classification["type"],
|
||||
error_classification.get("hint", ""),
|
||||
)
|
||||
# Freeze turn duration before terminal cleanup clears pending_started_at (#6309)
|
||||
_turn_duration_seconds = 0.0
|
||||
try:
|
||||
_pending_ts = getattr(session, 'pending_started_at', None)
|
||||
if _pending_ts:
|
||||
_turn_duration_seconds = max(0.0, time.time() - float(_pending_ts))
|
||||
except Exception:
|
||||
pass
|
||||
turn_duration = _terminal_turn_duration(session)
|
||||
_materialize_pending_user_turn_before_error(session)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
@@ -596,8 +746,9 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
) + (f"\n\n*{error_payload['hint']}*" if error_payload.get("hint") else ""),
|
||||
"timestamp": int(time.time()),
|
||||
"_error": True,
|
||||
"_turnDuration": round(_turn_duration_seconds, 3),
|
||||
}
|
||||
if turn_duration is not None:
|
||||
error_message["_turnDuration"] = turn_duration
|
||||
if error_payload.get("details"):
|
||||
error_message["provider_details"] = error_payload["details"]
|
||||
if not isinstance(session.messages, list):
|
||||
@@ -635,14 +786,10 @@ def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
|
||||
def _cleanup_gateway_pending_mirror(session_id: str) -> None:
|
||||
try:
|
||||
from api.route_approvals import (
|
||||
_approval_sse_notify_locked,
|
||||
_lock as _approval_lock,
|
||||
reconcile_gateway_pending_mirror_locked,
|
||||
retire_gateway_pending_mirror,
|
||||
)
|
||||
|
||||
with _approval_lock:
|
||||
head, total, _ = reconcile_gateway_pending_mirror_locked(session_id)
|
||||
_approval_sse_notify_locked(session_id, head, total)
|
||||
retire_gateway_pending_mirror(session_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to reconcile gateway pending mirror during teardown", exc_info=True)
|
||||
|
||||
@@ -668,6 +815,8 @@ def _run_gateway_chat_streaming(
|
||||
"""
|
||||
q = STREAMS.get(stream_id)
|
||||
if q is None:
|
||||
_finish_gateway_run_starting(stream_id, result="fallback")
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
# Cancelled before the worker started; release the owner entry the route
|
||||
# layer registered so STREAM_SESSION_OWNERS does not leak (no teardown finally runs).
|
||||
unregister_stream_owner(stream_id)
|
||||
@@ -695,6 +844,7 @@ def _run_gateway_chat_streaming(
|
||||
STREAM_LIVE_TOOL_CALLS[stream_id] = []
|
||||
|
||||
success_writeback_committed = False
|
||||
runs_api_pending_marked = True
|
||||
|
||||
def put_gateway_event(event, data):
|
||||
if cancel_event.is_set() and not success_writeback_committed and event not in ("cancel", "error", "apperror"):
|
||||
@@ -736,6 +886,22 @@ def _run_gateway_chat_streaming(
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
)
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
try:
|
||||
from api.config import _main_model_request_overrides
|
||||
_gw_overrides = _main_model_request_overrides(
|
||||
cfg,
|
||||
effective_model=model,
|
||||
effective_provider=model_provider,
|
||||
)
|
||||
except Exception:
|
||||
_gw_overrides = {}
|
||||
_runs_api_enabled = _gateway_use_runs_api_enabled(cfg)
|
||||
_use_runs_api = _runs_api_enabled and gateway_supports_approval(base_url, api_key)
|
||||
if not _use_runs_api and runs_api_pending_marked:
|
||||
_finish_gateway_run_starting(stream_id, result="fallback")
|
||||
runs_api_pending_marked = False
|
||||
try:
|
||||
from api.streaming import (
|
||||
_load_webui_prefill_context,
|
||||
@@ -774,19 +940,6 @@ def _run_gateway_chat_streaming(
|
||||
except Exception:
|
||||
logger.debug("Failed to load WebUI gateway prefill context", exc_info=True)
|
||||
prefill_messages = []
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
try:
|
||||
from api.config import _main_model_request_overrides
|
||||
_gw_overrides = _main_model_request_overrides(
|
||||
cfg,
|
||||
effective_model=model,
|
||||
effective_provider=model_provider,
|
||||
)
|
||||
except Exception:
|
||||
_gw_overrides = {}
|
||||
# Capability gate: use runs API when gateway advertises approval support.
|
||||
_use_runs_api = _gateway_use_runs_api_enabled(cfg) and gateway_supports_approval(base_url, api_key)
|
||||
if _use_runs_api:
|
||||
body_extras = {}
|
||||
if model_provider:
|
||||
@@ -912,12 +1065,13 @@ def _run_gateway_chat_streaming(
|
||||
_approval_run_id = str(approval_data.get("run_id") or "").strip()
|
||||
if _approval_run_id:
|
||||
_STREAM_RUN_IDS[stream_id] = _approval_run_id
|
||||
put_gateway_event("approval", approval_data)
|
||||
try:
|
||||
from api.route_approvals import submit_gateway_pending_mirror
|
||||
submit_gateway_pending_mirror(session_id, approval_data)
|
||||
head, total = submit_gateway_pending_mirror(session_id, approval_data)
|
||||
approval_data = {**(head or approval_data), "pending_count": total}
|
||||
except Exception:
|
||||
logger.debug("submit_gateway_pending_mirror failed", exc_info=True)
|
||||
put_gateway_event("approval", approval_data)
|
||||
else:
|
||||
logger.debug("Ignoring malformed gateway approval payload")
|
||||
sse_event = "message"
|
||||
@@ -1184,6 +1338,13 @@ def _run_gateway_chat_streaming(
|
||||
"hint": "Check HERMES_WEBUI_GATEWAY_BASE_URL and Gateway API server health.",
|
||||
})
|
||||
finally:
|
||||
mapped_run_id = str(_STREAM_RUN_IDS.get(stream_id) or "").strip()
|
||||
if mapped_run_id:
|
||||
try:
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=mapped_run_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to retire gateway pending mirrors during teardown", exc_info=True)
|
||||
if s is not None:
|
||||
try:
|
||||
with _get_session_agent_lock(session_id):
|
||||
@@ -1192,6 +1353,7 @@ def _run_gateway_chat_streaming(
|
||||
logger.debug("Failed to clear gateway stream state", exc_info=True)
|
||||
_cleanup_gateway_pending_mirror(session_id)
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES.pop(stream_id, None)
|
||||
CANCEL_FLAGS.pop(stream_id, None)
|
||||
STREAM_GOAL_RELATED.pop(stream_id, None)
|
||||
STREAM_PARTIAL_TEXT.pop(stream_id, None)
|
||||
@@ -1199,5 +1361,8 @@ def _run_gateway_chat_streaming(
|
||||
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)
|
||||
STREAM_LAST_EVENT_ID.pop(stream_id, None)
|
||||
STREAMS.pop(stream_id, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
if runs_api_pending_marked and gateway_run_id_pending(stream_id):
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
unregister_stream_owner(stream_id)
|
||||
unregister_active_run(stream_id)
|
||||
+418
-2
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Hermes Web UI -- HTTP helper functions.
|
||||
"""
|
||||
import base64 as _base64
|
||||
import binascii as _binascii
|
||||
import functools
|
||||
import json as _json
|
||||
import logging
|
||||
@@ -341,6 +343,13 @@ def _build_redact_fn():
|
||||
r"""(Authorization:\s*(?:Bearer|Bot)\s+)([^\s'",\]\)]+)""",
|
||||
_re.IGNORECASE,
|
||||
)
|
||||
# A rejected image data URI can place a syntactically valid AWS key at an
|
||||
# arbitrary base64 alignment, immediately after another base64 character.
|
||||
# The general credential regex uses token boundaries to avoid rewriting
|
||||
# prose identifiers; this defense-in-depth pass ensures the fail-closed
|
||||
# image path still removes the maintainer's embedded-suffix attack even
|
||||
# when hermes-agent is unavailable and the local fallback owns redaction.
|
||||
_EMBEDDED_AWS_ACCESS_KEY_RE = _re.compile(r"AKIA[A-Z0-9]{16}")
|
||||
_ENV_RE = _re.compile(
|
||||
r"([A-Z0-9_]{0,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})"
|
||||
r"\s*=\s*(['\"]?)(\S+)\2"
|
||||
@@ -408,6 +417,7 @@ def _build_redact_fn():
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
text = _CRED_RE.sub(lambda m: _mask(m.group(1)), text)
|
||||
text = _EMBEDDED_AWS_ACCESS_KEY_RE.sub(lambda m: _mask(m.group(0)), text)
|
||||
text = _AUTH_HDR_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), text)
|
||||
text = _ENV_RE.sub(_env_replacement, text)
|
||||
text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
||||
@@ -576,6 +586,360 @@ def _redact_text(text: str, *, _enabled: bool | None = None) -> str:
|
||||
return _redact_fn_cached(text)
|
||||
|
||||
|
||||
_RASTER_IMAGE_DATA_URI_PREFIXES = (
|
||||
("data:image/png;base64,", "png"),
|
||||
("data:image/jpeg;base64,", "jpeg"),
|
||||
("data:image/jpg;base64,", "jpeg"),
|
||||
("data:image/gif;base64,", "gif"),
|
||||
("data:image/webp;base64,", "webp"),
|
||||
("data:image/bmp;base64,", "bmp"),
|
||||
)
|
||||
|
||||
|
||||
def _is_native_raster_data_uri(text: str) -> bool:
|
||||
"""Return whether *text* is one complete, canonical raster data URI.
|
||||
|
||||
Native image content is opaque binary, not text that the credential regexes
|
||||
can safely rewrite. The exemption is a credential-boundary decision, so a
|
||||
matching header or magic prefix is not enough: decode the entire canonical
|
||||
base64 payload and require the image format to terminate exactly at the end
|
||||
of the decoded bytes. Any malformed, ambiguous, or trailing content falls
|
||||
through to normal text redaction.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
image_kind = None
|
||||
payload_start = 0
|
||||
for prefix, candidate_kind in _RASTER_IMAGE_DATA_URI_PREFIXES:
|
||||
# URI schemes and MIME type tokens are case-insensitive. Only normalize
|
||||
# this short header slice — never the multi-megabyte base64 payload.
|
||||
if text[:len(prefix)].lower() == prefix:
|
||||
image_kind = candidate_kind
|
||||
payload_start = len(prefix)
|
||||
break
|
||||
if image_kind is None:
|
||||
return False
|
||||
|
||||
payload = text[payload_start:]
|
||||
if not payload:
|
||||
return False
|
||||
try:
|
||||
raw = _base64.b64decode(payload, validate=True)
|
||||
except (_binascii.Error, ValueError):
|
||||
return False
|
||||
# validate=True rejects foreign characters and misplaced padding; this
|
||||
# round-trip also rejects non-canonical pad bits and missing/extra padding.
|
||||
if _base64.b64encode(raw).decode("ascii") != payload:
|
||||
return False
|
||||
|
||||
if image_kind == "png":
|
||||
return _is_complete_png(raw)
|
||||
if image_kind == "jpeg":
|
||||
return _is_complete_jpeg(raw)
|
||||
if image_kind == "gif":
|
||||
return _is_complete_gif(raw)
|
||||
if image_kind == "webp":
|
||||
return _is_complete_webp(raw)
|
||||
if image_kind == "bmp":
|
||||
return _is_complete_bmp(raw)
|
||||
return False
|
||||
|
||||
|
||||
def _is_complete_png(raw: bytes) -> bool:
|
||||
if not raw.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return False
|
||||
pos = 8
|
||||
chunk_index = 0
|
||||
saw_idat = False
|
||||
while pos < len(raw):
|
||||
if pos + 12 > len(raw):
|
||||
return False
|
||||
length = int.from_bytes(raw[pos:pos + 4], "big")
|
||||
chunk_type = raw[pos + 4:pos + 8]
|
||||
data_start = pos + 8
|
||||
data_end = data_start + length
|
||||
chunk_end = data_end + 4
|
||||
if chunk_end > len(raw):
|
||||
return False
|
||||
if not all((65 <= value <= 90) or (97 <= value <= 122) for value in chunk_type):
|
||||
return False
|
||||
if not 65 <= chunk_type[2] <= 90: # PNG reserved bit must be zero.
|
||||
return False
|
||||
expected_crc = int.from_bytes(raw[data_end:chunk_end], "big")
|
||||
actual_crc = _binascii.crc32(chunk_type + raw[data_start:data_end]) & 0xFFFFFFFF
|
||||
if actual_crc != expected_crc:
|
||||
return False
|
||||
|
||||
if chunk_index == 0:
|
||||
if chunk_type != b"IHDR" or length != 13:
|
||||
return False
|
||||
width = int.from_bytes(raw[data_start:data_start + 4], "big")
|
||||
height = int.from_bytes(raw[data_start + 4:data_start + 8], "big")
|
||||
bit_depth = raw[data_start + 8]
|
||||
color_type = raw[data_start + 9]
|
||||
valid_depths = {
|
||||
0: {1, 2, 4, 8, 16},
|
||||
2: {8, 16},
|
||||
3: {1, 2, 4, 8},
|
||||
4: {8, 16},
|
||||
6: {8, 16},
|
||||
}
|
||||
if (
|
||||
not width
|
||||
or not height
|
||||
or bit_depth not in valid_depths.get(color_type, set())
|
||||
or raw[data_start + 10] != 0
|
||||
or raw[data_start + 11] != 0
|
||||
or raw[data_start + 12] not in {0, 1}
|
||||
):
|
||||
return False
|
||||
elif chunk_type == b"IHDR":
|
||||
return False
|
||||
|
||||
if chunk_type == b"IDAT":
|
||||
saw_idat = True
|
||||
if chunk_type == b"IEND":
|
||||
return length == 0 and saw_idat and chunk_end == len(raw)
|
||||
pos = chunk_end
|
||||
chunk_index += 1
|
||||
return False
|
||||
|
||||
|
||||
_JPEG_SOF_MARKERS = {
|
||||
0xC0, 0xC1, 0xC2, 0xC3,
|
||||
0xC5, 0xC6, 0xC7,
|
||||
0xC9, 0xCA, 0xCB,
|
||||
0xCD, 0xCE, 0xCF,
|
||||
}
|
||||
|
||||
|
||||
def _is_complete_jpeg(raw: bytes) -> bool:
|
||||
if len(raw) < 4 or raw[:2] != b"\xff\xd8":
|
||||
return False
|
||||
pos = 2
|
||||
saw_sof = False
|
||||
saw_scan = False
|
||||
while pos < len(raw):
|
||||
marker_start = pos
|
||||
if raw[pos] != 0xFF:
|
||||
return False
|
||||
while pos < len(raw) and raw[pos] == 0xFF:
|
||||
pos += 1
|
||||
if pos >= len(raw):
|
||||
return False
|
||||
marker = raw[pos]
|
||||
pos += 1
|
||||
if marker == 0xD9:
|
||||
return saw_sof and saw_scan and pos == len(raw)
|
||||
if marker in {0x00, 0x01, 0xD8} or 0xD0 <= marker <= 0xD7:
|
||||
return False
|
||||
if pos + 2 > len(raw):
|
||||
return False
|
||||
segment_length = int.from_bytes(raw[pos:pos + 2], "big")
|
||||
if segment_length < 2:
|
||||
return False
|
||||
segment_end = pos + segment_length
|
||||
if segment_end > len(raw):
|
||||
return False
|
||||
if marker in _JPEG_SOF_MARKERS:
|
||||
if segment_length < 8:
|
||||
return False
|
||||
saw_sof = True
|
||||
if marker != 0xDA:
|
||||
pos = segment_end
|
||||
continue
|
||||
|
||||
saw_scan = True
|
||||
pos = segment_end
|
||||
while pos < len(raw):
|
||||
if raw[pos] != 0xFF:
|
||||
pos += 1
|
||||
continue
|
||||
marker_start = pos
|
||||
while pos < len(raw) and raw[pos] == 0xFF:
|
||||
pos += 1
|
||||
if pos >= len(raw):
|
||||
return False
|
||||
scan_marker = raw[pos]
|
||||
if scan_marker == 0x00 or 0xD0 <= scan_marker <= 0xD7:
|
||||
pos += 1
|
||||
continue
|
||||
pos = marker_start
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _gif_subblocks_end(raw: bytes, pos: int) -> int | None:
|
||||
while pos < len(raw):
|
||||
size = raw[pos]
|
||||
pos += 1
|
||||
if size == 0:
|
||||
return pos
|
||||
if pos + size > len(raw):
|
||||
return None
|
||||
pos += size
|
||||
return None
|
||||
|
||||
|
||||
def _is_complete_gif(raw: bytes) -> bool:
|
||||
if len(raw) < 14 or not raw.startswith((b"GIF87a", b"GIF89a")):
|
||||
return False
|
||||
width = int.from_bytes(raw[6:8], "little")
|
||||
height = int.from_bytes(raw[8:10], "little")
|
||||
if not width or not height:
|
||||
return False
|
||||
packed = raw[10]
|
||||
pos = 13
|
||||
if packed & 0x80:
|
||||
pos += 3 * (1 << ((packed & 0x07) + 1))
|
||||
if pos > len(raw):
|
||||
return False
|
||||
saw_image = False
|
||||
while pos < len(raw):
|
||||
introducer = raw[pos]
|
||||
pos += 1
|
||||
if introducer == 0x3B:
|
||||
return saw_image and pos == len(raw)
|
||||
if introducer == 0x21:
|
||||
if pos >= len(raw):
|
||||
return False
|
||||
pos += 1 # extension label
|
||||
end = _gif_subblocks_end(raw, pos)
|
||||
if end is None:
|
||||
return False
|
||||
pos = end
|
||||
continue
|
||||
if introducer != 0x2C or pos + 9 > len(raw):
|
||||
return False
|
||||
image_width = int.from_bytes(raw[pos + 4:pos + 6], "little")
|
||||
image_height = int.from_bytes(raw[pos + 6:pos + 8], "little")
|
||||
image_packed = raw[pos + 8]
|
||||
if not image_width or not image_height:
|
||||
return False
|
||||
pos += 9
|
||||
if image_packed & 0x80:
|
||||
pos += 3 * (1 << ((image_packed & 0x07) + 1))
|
||||
if pos >= len(raw):
|
||||
return False
|
||||
lzw_minimum_code_size = raw[pos]
|
||||
if not 2 <= lzw_minimum_code_size <= 11:
|
||||
return False
|
||||
pos += 1
|
||||
end = _gif_subblocks_end(raw, pos)
|
||||
if end is None:
|
||||
return False
|
||||
pos = end
|
||||
saw_image = True
|
||||
return False
|
||||
|
||||
|
||||
def _is_webp_image_chunk(chunk_type: bytes, data: bytes) -> bool:
|
||||
if chunk_type == b"VP8 ":
|
||||
if len(data) < 10 or data[3:6] != b"\x9d\x01\x2a":
|
||||
return False
|
||||
width = int.from_bytes(data[6:8], "little") & 0x3FFF
|
||||
height = int.from_bytes(data[8:10], "little") & 0x3FFF
|
||||
return bool(width and height)
|
||||
if chunk_type == b"VP8L":
|
||||
# The three high bits of the fifth byte are the version number. The
|
||||
# current lossless bitstream defines only version zero.
|
||||
return len(data) >= 5 and data[0] == 0x2F and not data[4] & 0xE0
|
||||
return False
|
||||
|
||||
|
||||
def _is_complete_webp_frame(data: bytes) -> bool:
|
||||
"""Validate the nested chunks in one extended-WebP animation frame."""
|
||||
if len(data) < 16 or data[15] & 0xFC:
|
||||
return False
|
||||
pos = 16
|
||||
saw_image = False
|
||||
while pos < len(data):
|
||||
if pos + 8 > len(data):
|
||||
return False
|
||||
chunk_type = data[pos:pos + 4]
|
||||
chunk_size = int.from_bytes(data[pos + 4:pos + 8], "little")
|
||||
data_start = pos + 8
|
||||
data_end = data_start + chunk_size
|
||||
chunk_end = data_end + (chunk_size & 1)
|
||||
if chunk_end > len(data):
|
||||
return False
|
||||
chunk_data = data[data_start:data_end]
|
||||
if chunk_type in {b"VP8 ", b"VP8L"}:
|
||||
if saw_image or not _is_webp_image_chunk(chunk_type, chunk_data):
|
||||
return False
|
||||
saw_image = True
|
||||
elif chunk_type != b"ALPH":
|
||||
return False
|
||||
pos = chunk_end
|
||||
return saw_image and pos == len(data)
|
||||
|
||||
|
||||
def _is_complete_webp(raw: bytes) -> bool:
|
||||
if (
|
||||
len(raw) < 20
|
||||
or raw[:4] != b"RIFF"
|
||||
or raw[8:12] != b"WEBP"
|
||||
or int.from_bytes(raw[4:8], "little") + 8 != len(raw)
|
||||
):
|
||||
return False
|
||||
pos = 12
|
||||
saw_image = False
|
||||
while pos < len(raw):
|
||||
if pos + 8 > len(raw):
|
||||
return False
|
||||
chunk_type = raw[pos:pos + 4]
|
||||
chunk_size = int.from_bytes(raw[pos + 4:pos + 8], "little")
|
||||
data_start = pos + 8
|
||||
data_end = data_start + chunk_size
|
||||
chunk_end = data_end + (chunk_size & 1)
|
||||
if chunk_end > len(raw):
|
||||
return False
|
||||
data = raw[data_start:data_end]
|
||||
if chunk_type in {b"VP8 ", b"VP8L"}:
|
||||
if saw_image or not _is_webp_image_chunk(chunk_type, data):
|
||||
return False
|
||||
saw_image = True
|
||||
elif chunk_type == b"VP8X":
|
||||
if len(data) != 10 or data[0] & 0x81 or any(data[1:4]):
|
||||
return False
|
||||
elif chunk_type == b"ANMF":
|
||||
if not _is_complete_webp_frame(data):
|
||||
return False
|
||||
saw_image = True
|
||||
pos = chunk_end
|
||||
return saw_image and pos == len(raw)
|
||||
|
||||
|
||||
def _is_complete_bmp(raw: bytes) -> bool:
|
||||
if len(raw) < 26 or raw[:2] != b"BM":
|
||||
return False
|
||||
if int.from_bytes(raw[2:6], "little") != len(raw):
|
||||
return False
|
||||
pixel_offset = int.from_bytes(raw[10:14], "little")
|
||||
dib_size = int.from_bytes(raw[14:18], "little")
|
||||
if dib_size == 12:
|
||||
width = int.from_bytes(raw[18:20], "little")
|
||||
height = int.from_bytes(raw[20:22], "little")
|
||||
planes = int.from_bytes(raw[22:24], "little")
|
||||
bits_per_pixel = int.from_bytes(raw[24:26], "little")
|
||||
elif dib_size >= 40 and 14 + dib_size <= len(raw):
|
||||
width = int.from_bytes(raw[18:22], "little", signed=True)
|
||||
height = int.from_bytes(raw[22:26], "little", signed=True)
|
||||
planes = int.from_bytes(raw[26:28], "little")
|
||||
bits_per_pixel = int.from_bytes(raw[28:30], "little")
|
||||
else:
|
||||
return False
|
||||
return (
|
||||
bool(width)
|
||||
and bool(height)
|
||||
and planes == 1
|
||||
and bits_per_pixel in {1, 2, 4, 8, 16, 24, 32}
|
||||
and 14 + dib_size <= pixel_offset < len(raw)
|
||||
)
|
||||
|
||||
|
||||
def _redact_value(v, *, _enabled: bool | None = None):
|
||||
"""Recursively redact credentials from strings, dicts, and lists.
|
||||
|
||||
@@ -585,12 +949,64 @@ def _redact_value(v, *, _enabled: bool | None = None):
|
||||
if isinstance(v, str):
|
||||
return _redact_text(v, _enabled=_enabled)
|
||||
if isinstance(v, dict):
|
||||
return {k: _redact_value(val, _enabled=_enabled) for k, val in v.items()}
|
||||
return {
|
||||
key: _redact_value(value, _enabled=_enabled)
|
||||
for key, value in v.items()
|
||||
}
|
||||
if isinstance(v, list):
|
||||
return [_redact_value(item, _enabled=_enabled) for item in v]
|
||||
return v
|
||||
|
||||
|
||||
def _redact_message_content_part(part, *, _enabled: bool):
|
||||
"""Redact one canonical ``messages[*].content[*]`` part.
|
||||
|
||||
The raster exemption exists only at this authoritative schema position.
|
||||
Image-shaped dictionaries in metadata, tools, todos, journals, or arbitrary
|
||||
nested values remain on the normal fail-closed redaction path.
|
||||
"""
|
||||
if not _enabled or not (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "image_url"
|
||||
and isinstance(part.get("image_url"), dict)
|
||||
):
|
||||
return _redact_value(part, _enabled=_enabled)
|
||||
result = {}
|
||||
for key, value in part.items():
|
||||
if key != "image_url":
|
||||
result[key] = _redact_value(value, _enabled=_enabled)
|
||||
continue
|
||||
result[key] = {
|
||||
image_key: image_value
|
||||
if image_key == "url" and _is_native_raster_data_uri(image_value)
|
||||
else _redact_value(image_value, _enabled=_enabled)
|
||||
for image_key, image_value in value.items()
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _redact_messages(messages, *, _enabled: bool):
|
||||
if not isinstance(messages, list):
|
||||
return _redact_value(messages, _enabled=_enabled)
|
||||
redacted = []
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
redacted.append(_redact_value(message, _enabled=_enabled))
|
||||
continue
|
||||
item = {}
|
||||
allow_native_image = message.get("role") == "user"
|
||||
for key, value in message.items():
|
||||
if allow_native_image and key == "content" and isinstance(value, list):
|
||||
item[key] = [
|
||||
_redact_message_content_part(part, _enabled=_enabled)
|
||||
for part in value
|
||||
]
|
||||
else:
|
||||
item[key] = _redact_value(value, _enabled=_enabled)
|
||||
redacted.append(item)
|
||||
return redacted
|
||||
|
||||
|
||||
def redact_session_data(session_dict: dict) -> dict:
|
||||
"""Redact credentials from message content, tool data, and session sidecars.
|
||||
|
||||
@@ -610,7 +1026,7 @@ def redact_session_data(session_dict: dict) -> dict:
|
||||
if isinstance(result.get('title'), str):
|
||||
result['title'] = _redact_text(result['title'], _enabled=_enabled)
|
||||
if 'messages' in result:
|
||||
result['messages'] = _redact_value(result['messages'], _enabled=_enabled)
|
||||
result['messages'] = _redact_messages(result['messages'], _enabled=_enabled)
|
||||
if 'tool_calls' in result:
|
||||
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
|
||||
if 'todo_state' in result:
|
||||
|
||||
@@ -7989,6 +7989,7 @@ _SESSION_MESSAGE_DISPLAY_METADATA_KEYS = (
|
||||
"_turnTps",
|
||||
"_turnUsage",
|
||||
"_firstTokenMs",
|
||||
"_usedModel",
|
||||
"_gatewayRouting",
|
||||
"_statusCard",
|
||||
"_anchor_stream_id",
|
||||
|
||||
+3
-1
@@ -28,6 +28,7 @@ from api.config import (
|
||||
save_settings,
|
||||
verify_hermes_imports,
|
||||
)
|
||||
from api.paths import _atomic_write_text
|
||||
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
|
||||
from api.workspace import get_last_workspace, load_workspaces
|
||||
|
||||
@@ -251,7 +252,8 @@ def _save_yaml_config(config_path: Path, config: dict) -> None:
|
||||
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
_atomic_write_text(
|
||||
config_path,
|
||||
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
+246
@@ -5,12 +5,258 @@ that need the default Hermes home can import them without triggering config's
|
||||
larger startup side effects.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
|
||||
|
||||
def _create_atomic_temp_file(write_path: Path, *, existing: bool) -> tuple[int, str]:
|
||||
"""Create a same-directory temp file without reading the process umask.
|
||||
|
||||
``tempfile.mkstemp`` deliberately creates files as ``0600``. For a brand-new
|
||||
config, use ``os.open(..., 0666)`` instead so the kernel applies the active
|
||||
process umask as it would for ``Path.write_text``. Reading umask by calling
|
||||
``os.umask`` is unsafe because that syscall mutates process-wide state, and
|
||||
imports may happen after the threaded server has started.
|
||||
"""
|
||||
if existing:
|
||||
return tempfile.mkstemp(
|
||||
dir=str(write_path.parent), prefix=f".{write_path.name}_", suffix=".tmp"
|
||||
)
|
||||
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
|
||||
for _ in range(100):
|
||||
tmp = write_path.parent / f".{write_path.name}_{secrets.token_hex(16)}.tmp"
|
||||
try:
|
||||
return os.open(tmp, flags, 0o666), str(tmp)
|
||||
except FileExistsError:
|
||||
continue
|
||||
raise FileExistsError("unable to allocate unique config temporary file")
|
||||
|
||||
# In-place compatibility writes cannot use rename isolation. Serialize every
|
||||
# such write in this process so concurrent WebUI saves cannot interleave their
|
||||
# truncate/write/fsync sequences. A single lock avoids a permanent per-path
|
||||
# lock cache for arbitrary profile/config locations.
|
||||
_IN_PLACE_WRITE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _fsync_directory(directory: Path) -> None:
|
||||
"""Persist a completed rename on filesystems that support directory fsync."""
|
||||
if os.name == "nt":
|
||||
return
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
try:
|
||||
fd = os.open(directory, flags)
|
||||
except PermissionError:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except OSError as exc:
|
||||
if exc.errno not in {errno.EINVAL, errno.ENOTSUP}:
|
||||
raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _has_extended_attributes(path: Path) -> bool:
|
||||
"""Return whether *path* has xattrs that an inode replacement would discard."""
|
||||
listxattr = getattr(os, "listxattr", None)
|
||||
if listxattr is None:
|
||||
return False
|
||||
try:
|
||||
return bool(listxattr(path))
|
||||
except OSError as exc:
|
||||
unsupported_errnos = {
|
||||
errno.ENOSYS,
|
||||
errno.ENOTSUP,
|
||||
getattr(errno, "EOPNOTSUPP", errno.ENOTSUP),
|
||||
}
|
||||
if exc.errno in unsupported_errnos:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def _require_writable_target(write_path: Path) -> os.stat_result | None:
|
||||
"""Reject writes to a read-only target before any replacement work.
|
||||
|
||||
``os.replace`` happily swaps a fresh inode over a ``0444`` file when the
|
||||
directory is writable, which would silently defeat a deliberately locked
|
||||
config. The old in-place ``Path.write_text`` raised ``PermissionError``
|
||||
there; preserve that contract with a non-truncating ``O_WRONLY`` probe,
|
||||
letting the failure propagate. Returns the ``fstat`` of the inode the
|
||||
probe actually opened (so the caller's metadata describes the verified
|
||||
file even if a concurrent writer replaced it since its own ``stat``), or
|
||||
``None`` if the target vanished concurrently.
|
||||
"""
|
||||
try:
|
||||
probe_fd = os.open(write_path, os.O_WRONLY)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return os.fstat(probe_fd)
|
||||
finally:
|
||||
os.close(probe_fd)
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
|
||||
"""Atomically replace *path* with *text*.
|
||||
|
||||
Writes to a temp file in the same directory, flushes + ``os.fsync``, then
|
||||
``os.replace``s it into place, so a crash (or exception) mid-write can never
|
||||
truncate an existing file — the old contents stay intact until the rename
|
||||
commits the new ones in one step. Mirrors the tempfile+fsync+os.replace
|
||||
pattern already used for ``.env`` and cost snapshots in ``api.providers``;
|
||||
extracted here so ``config.yaml`` / profile ``config.yaml`` writers can share
|
||||
it without pulling in env-file-specific logic.
|
||||
|
||||
Permissions and ownership are preserved: ``os.replace`` carries the temp
|
||||
file's metadata onto the target, and ``tempfile.mkstemp`` starts with the
|
||||
writer's owner/group and mode ``0600``. Without correcting those values,
|
||||
every rewrite could silently reset a shared config's group and tighten a
|
||||
group/other-readable ``config.yaml`` (commonly ``0644``/``0664``) down to
|
||||
owner-only. Existing files copy uid/gid and mode onto the temp descriptor;
|
||||
new files use the active process umask, exactly as a normal
|
||||
``open(..., "w")`` would.
|
||||
(Unlike ``.env``, ``config.yaml`` holds no secrets and is not meant to be
|
||||
forced to ``0600``.)
|
||||
|
||||
Symlinks and hard links keep the same follow-through semantics as
|
||||
``Path.write_text``: writing ``config.yaml`` through a symlink updates the
|
||||
referent, while writing an inode with multiple hard links updates every
|
||||
alias rather than severing the selected path. A symlink target change
|
||||
observed before commit is rejected rather than updating a stale referent.
|
||||
|
||||
The temp+rename dance needs WRITE PERMISSION ON THE DIRECTORY, which the
|
||||
plain ``Path.write_text`` it replaced did not: hardened deployments
|
||||
(``HERMES_CONFIG_PATH`` pointing into a root-owned, read-only dir with a
|
||||
writable ``config.yaml`` inside) would otherwise lose the ability to save
|
||||
settings at all. When temp creation is denied, or the original uid/gid
|
||||
cannot be transferred to the temp inode, we fall back to a guarded
|
||||
descriptor write only if the target is still the regular-file inode
|
||||
inspected above. Hard-linked files and files with extended attributes use
|
||||
that same path because replacing their directory entry would respectively
|
||||
break ``Path.write_text`` compatibility or discard metadata such as POSIX
|
||||
ACLs. These in-place writes are serialized across process-local WebUI
|
||||
writers. They give up crash-atomicity (and raw concurrent readers may
|
||||
observe the write in progress) only where atomic replace with preserved
|
||||
semantics is impossible. Other ``PermissionError``s still propagate.
|
||||
Successful renames fsync the parent directory where supported.
|
||||
|
||||
The caller is responsible for ensuring ``path.parent`` exists.
|
||||
"""
|
||||
path = Path(path)
|
||||
symlink_target = path.resolve(strict=False) if path.is_symlink() else None
|
||||
write_path = symlink_target or path
|
||||
try:
|
||||
existing_stat = os.stat(write_path)
|
||||
except FileNotFoundError:
|
||||
existing_stat = None
|
||||
mode = stat.S_IMODE(existing_stat.st_mode) if existing_stat else None
|
||||
|
||||
def _verify_symlink_target() -> None:
|
||||
if symlink_target is not None and path.resolve(strict=False) != symlink_target:
|
||||
raise RuntimeError("config symlink target changed during atomic write")
|
||||
|
||||
def _write_in_place() -> None:
|
||||
with _IN_PLACE_WRITE_LOCK:
|
||||
if existing_stat is None or not stat.S_ISREG(existing_stat.st_mode):
|
||||
raise PermissionError(f"Cannot replace config path: {write_path}")
|
||||
_verify_symlink_target()
|
||||
fallback_fd = os.open(write_path, os.O_WRONLY)
|
||||
owns_fallback_fd = True
|
||||
try:
|
||||
opened_stat = os.fstat(fallback_fd)
|
||||
if (opened_stat.st_dev, opened_stat.st_ino) != (
|
||||
existing_stat.st_dev,
|
||||
existing_stat.st_ino,
|
||||
):
|
||||
raise PermissionError("config target changed before fallback write")
|
||||
_verify_symlink_target()
|
||||
os.ftruncate(fallback_fd, 0)
|
||||
fallback_file = os.fdopen(fallback_fd, "w", encoding=encoding)
|
||||
owns_fallback_fd = False
|
||||
with fallback_file:
|
||||
fallback_file.write(text)
|
||||
fallback_file.flush()
|
||||
os.fsync(fallback_file.fileno())
|
||||
finally:
|
||||
if owns_fallback_fd:
|
||||
os.close(fallback_fd)
|
||||
|
||||
if existing_stat is not None and stat.S_ISREG(existing_stat.st_mode):
|
||||
probed_stat = _require_writable_target(write_path)
|
||||
if probed_stat is not None and stat.S_ISREG(probed_stat.st_mode):
|
||||
existing_stat = probed_stat
|
||||
mode = stat.S_IMODE(existing_stat.st_mode)
|
||||
else:
|
||||
existing_stat = probed_stat
|
||||
mode = None
|
||||
if existing_stat is not None and (
|
||||
existing_stat.st_nlink > 1 or _has_extended_attributes(write_path)
|
||||
):
|
||||
_write_in_place()
|
||||
return
|
||||
|
||||
try:
|
||||
fd, tmp = _create_atomic_temp_file(write_path, existing=existing_stat is not None)
|
||||
except PermissionError:
|
||||
_write_in_place()
|
||||
return
|
||||
owns_fd = True
|
||||
try:
|
||||
if existing_stat is not None and hasattr(os, "fchown"):
|
||||
try:
|
||||
os.fchown(fd, existing_stat.st_uid, existing_stat.st_gid)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
unsupported_ownership_errnos = {
|
||||
errno.EINVAL,
|
||||
errno.ENOSYS,
|
||||
errno.ENOTSUP,
|
||||
}
|
||||
if (
|
||||
isinstance(exc, OSError)
|
||||
and not isinstance(exc, PermissionError)
|
||||
and exc.errno not in unsupported_ownership_errnos
|
||||
):
|
||||
raise
|
||||
os.close(fd)
|
||||
owns_fd = False
|
||||
os.unlink(tmp)
|
||||
_write_in_place()
|
||||
return
|
||||
if mode is not None and hasattr(os, "fchmod"):
|
||||
os.fchmod(fd, mode)
|
||||
elif mode is not None:
|
||||
os.chmod(tmp, mode)
|
||||
f = os.fdopen(fd, "w", encoding=encoding)
|
||||
owns_fd = False
|
||||
with f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
_verify_symlink_target()
|
||||
os.replace(tmp, write_path)
|
||||
_fsync_directory(write_path.parent)
|
||||
except BaseException:
|
||||
if owns_fd:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _hermes_home_has_webui_state(base: Path) -> bool:
|
||||
"""Return True when *base* holds real WebUI state under its ``webui/`` dir.
|
||||
|
||||
|
||||
+153
-40
@@ -21,6 +21,7 @@ from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from api.paths import _atomic_write_text
|
||||
from api.session_events import publish_session_list_changed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,6 +57,7 @@ _loaded_profile_env_keys: set[str] = set()
|
||||
_tls = threading.local()
|
||||
|
||||
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def snapshot_skill_home_modules() -> dict[str, dict[str, object]]:
|
||||
@@ -76,6 +78,71 @@ def snapshot_skill_home_modules() -> dict[str, dict[str, object]]:
|
||||
return snapshot
|
||||
|
||||
|
||||
def _skill_modules_support_profile_home(profile_home: Path) -> bool:
|
||||
"""Return ``True`` when both skill modules resolve to this profile home."""
|
||||
expected_dir = (Path(profile_home) / 'skills').expanduser()
|
||||
|
||||
for module_name in _SKILL_HOME_MODULES:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
logger.debug("Skill capability check: %s not pre-imported in sys.modules", module_name)
|
||||
return False
|
||||
|
||||
if not hasattr(module, 'SKILLS_DIR'):
|
||||
logger.debug("Skill capability check: %s missing SKILLS_DIR", module_name)
|
||||
return False
|
||||
|
||||
if not hasattr(module, '_SKILLS_DIR_AT_IMPORT'):
|
||||
logger.debug("Skill capability check: %s missing _SKILLS_DIR_AT_IMPORT", module_name)
|
||||
return False
|
||||
|
||||
try:
|
||||
current_skills_dir = Path(module.SKILLS_DIR).expanduser()
|
||||
import_skills_dir = Path(module._SKILLS_DIR_AT_IMPORT).expanduser()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Skill capability check: %s has invalid SKILLS_DIR or _SKILLS_DIR_AT_IMPORT",
|
||||
module_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if current_skills_dir != import_skills_dir:
|
||||
logger.debug(
|
||||
"Skill capability check: %s.SKILLS_DIR %r does not match imported baseline %r",
|
||||
module_name,
|
||||
current_skills_dir,
|
||||
import_skills_dir,
|
||||
)
|
||||
return False
|
||||
|
||||
skills_dir = getattr(module, '_skills_dir', None)
|
||||
if not callable(skills_dir):
|
||||
logger.debug("Skill capability check: %s._skills_dir is not callable", module_name)
|
||||
return False
|
||||
|
||||
try:
|
||||
resolved = Path(skills_dir()).expanduser()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Skill capability check: %s._skills_dir() failed",
|
||||
module_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if resolved != expected_dir:
|
||||
logger.debug(
|
||||
"Skill capability check: %s resolves %r instead of %r",
|
||||
module_name,
|
||||
str(resolved),
|
||||
str(expected_dir),
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
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:
|
||||
@@ -1100,6 +1167,8 @@ def profile_env_for_background_worker(
|
||||
session,
|
||||
purpose: str = "background worker",
|
||||
logger_override: Optional[logging.Logger] = None,
|
||||
*,
|
||||
scope_skill_modules: bool = True,
|
||||
):
|
||||
"""Temporarily route detached worker config reads through a profile.
|
||||
|
||||
@@ -1152,10 +1221,15 @@ def profile_env_for_background_worker(
|
||||
)
|
||||
_scope_token = None
|
||||
_has_scope = False
|
||||
_secret_scope_mod = None
|
||||
# #5567: context-local Hermes-home override (hermes-agent v0.18.0+). None on
|
||||
# older agents → graceful no-op (falls back to the os.environ mirror below).
|
||||
_home_override_mod = None
|
||||
_home_override_token = None
|
||||
_home_override_installed = False
|
||||
has_profile_skill_home = False
|
||||
should_restore_skill_modules = False
|
||||
_acquired_skill_home_patch_lock = False
|
||||
try:
|
||||
_set_thread_env(**thread_env)
|
||||
_thread_ctx.block_process_env_fallback = True
|
||||
@@ -1180,9 +1254,43 @@ def profile_env_for_background_worker(
|
||||
_home_override_token = _home_override_mod.set_hermes_home_override(
|
||||
str(profile_home_path)
|
||||
)
|
||||
_home_override_installed = True
|
||||
except Exception:
|
||||
_home_override_token = None
|
||||
_home_override_installed = False
|
||||
|
||||
if scope_skill_modules:
|
||||
if _home_override_mod is not None and _home_override_installed:
|
||||
try:
|
||||
has_profile_skill_home = _skill_modules_support_profile_home(
|
||||
profile_home_path
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to evaluate profile-home skill module capability for %s in %s",
|
||||
profile,
|
||||
purpose,
|
||||
exc_info=True,
|
||||
)
|
||||
has_profile_skill_home = False
|
||||
|
||||
# #5567-fallback: if override is unavailable, or module-side
|
||||
# profile resolution is missing/failed, serialize the full worker
|
||||
# lifespan under the shared legacy patch lock.
|
||||
should_restore_skill_modules = not (
|
||||
_home_override_installed and has_profile_skill_home
|
||||
)
|
||||
if should_restore_skill_modules:
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK.acquire()
|
||||
_acquired_skill_home_patch_lock = True
|
||||
|
||||
with _ENV_LOCK:
|
||||
if scope_skill_modules and should_restore_skill_modules:
|
||||
# Snapshot and patch before mutating process env so setup
|
||||
# failures can unwind without leaking either state.
|
||||
skill_home_snapshot = snapshot_skill_home_modules()
|
||||
patch_skill_home_modules(profile_home_path)
|
||||
|
||||
old_runtime_env = _apply_profile_env_to_process(
|
||||
os.environ,
|
||||
safe_runtime_env,
|
||||
@@ -1190,48 +1298,43 @@ def profile_env_for_background_worker(
|
||||
)
|
||||
had_hermes_home = "HERMES_HOME" in os.environ
|
||||
old_hermes_home = os.environ.get("HERMES_HOME")
|
||||
skill_home_snapshot = snapshot_skill_home_modules()
|
||||
os.environ.update(safe_runtime_env)
|
||||
os.environ["HERMES_HOME"] = str(profile_home_path)
|
||||
try:
|
||||
patch_skill_home_modules(profile_home_path)
|
||||
except Exception:
|
||||
log.debug(
|
||||
"Failed to patch skill modules for %s profile %s",
|
||||
purpose,
|
||||
profile,
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
# #5567: pop the context-local home override first (reverse of setup order).
|
||||
if _home_override_mod is not None and _home_override_token is not None:
|
||||
try:
|
||||
_home_override_mod.reset_hermes_home_override(_home_override_token)
|
||||
except Exception:
|
||||
pass
|
||||
if _has_scope and _secret_scope_mod is not None:
|
||||
try:
|
||||
_secret_scope_mod.reset_secret_scope(_scope_token)
|
||||
except Exception:
|
||||
pass
|
||||
_thread_ctx.block_process_env_fallback = previous_block_process_env
|
||||
if previous_thread_env:
|
||||
_set_thread_env(**previous_thread_env)
|
||||
else:
|
||||
_clear_thread_env()
|
||||
with _ENV_LOCK:
|
||||
for key, old_value in old_runtime_env.items():
|
||||
if old_value is None:
|
||||
os.environ.pop(key, None)
|
||||
try:
|
||||
with _ENV_LOCK:
|
||||
for key, old_value in old_runtime_env.items():
|
||||
if old_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = old_value
|
||||
if had_hermes_home:
|
||||
os.environ["HERMES_HOME"] = old_hermes_home or ""
|
||||
else:
|
||||
os.environ[key] = old_value
|
||||
if had_hermes_home:
|
||||
os.environ["HERMES_HOME"] = old_hermes_home or ""
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
if should_restore_skill_modules and skill_home_snapshot is not None:
|
||||
restore_skill_home_modules(skill_home_snapshot)
|
||||
finally:
|
||||
if _acquired_skill_home_patch_lock:
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK.release()
|
||||
_acquired_skill_home_patch_lock = False
|
||||
# Reset context-local state after the fallback globals are restored.
|
||||
if _home_override_mod is not None and _home_override_installed:
|
||||
try:
|
||||
_home_override_mod.reset_hermes_home_override(_home_override_token)
|
||||
except Exception:
|
||||
pass
|
||||
if _has_scope and _secret_scope_mod is not None:
|
||||
try:
|
||||
_secret_scope_mod.reset_secret_scope(_scope_token)
|
||||
except Exception:
|
||||
pass
|
||||
_thread_ctx.block_process_env_fallback = previous_block_process_env
|
||||
if previous_thread_env:
|
||||
_set_thread_env(**previous_thread_env)
|
||||
else:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
if skill_home_snapshot is not None:
|
||||
restore_skill_home_modules(skill_home_snapshot)
|
||||
_clear_thread_env()
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -1292,6 +1395,7 @@ def profile_env_for_active_request_readonly(
|
||||
getattr(_thread_ctx, "block_process_env_fallback", False)
|
||||
)
|
||||
home_override_token = None
|
||||
home_override_installed = False
|
||||
_scope_token = None
|
||||
_has_scope = False
|
||||
try:
|
||||
@@ -1307,7 +1411,16 @@ def profile_env_for_active_request_readonly(
|
||||
except Exception:
|
||||
pass
|
||||
if set_hermes_home_override is not None:
|
||||
home_override_token = set_hermes_home_override(profile_home_path)
|
||||
try:
|
||||
home_override_token = set_hermes_home_override(profile_home_path)
|
||||
home_override_installed = True
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to install Hermes-home override for active request profile %s in %s",
|
||||
profile,
|
||||
purpose,
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
if _has_scope and _secret_scope_mod is not None:
|
||||
@@ -1315,7 +1428,7 @@ def profile_env_for_active_request_readonly(
|
||||
_secret_scope_mod.reset_secret_scope(_scope_token)
|
||||
except Exception:
|
||||
pass
|
||||
if home_override_token is not None and reset_hermes_home_override is not None:
|
||||
if reset_hermes_home_override is not None and home_override_installed:
|
||||
try:
|
||||
reset_hermes_home_override(home_override_token)
|
||||
except Exception:
|
||||
@@ -2262,7 +2375,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
|
||||
if base_url:
|
||||
model_section['base_url'] = base_url
|
||||
cfg['model'] = model_section
|
||||
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
|
||||
_atomic_write_text(config_path, _yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
|
||||
|
||||
|
||||
def _clean_profile_config_value(value: Optional[str], field: str) -> Optional[str]:
|
||||
@@ -2401,7 +2514,7 @@ def _write_model_defaults_to_config(
|
||||
if model_provider:
|
||||
model_section['provider'] = model_provider
|
||||
cfg['model'] = model_section
|
||||
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
|
||||
_atomic_write_text(config_path, _yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
|
||||
|
||||
|
||||
def create_profile_api(name: str, clone_from: str = None,
|
||||
|
||||
+415
-24
@@ -46,7 +46,10 @@ except ImportError:
|
||||
_approval_sse_subscribers: dict[str, list[queue.Queue]] = {}
|
||||
_GATEWAY_MIRROR_FLAG = "_gateway_mirror"
|
||||
_GATEWAY_MIRROR_TOKEN = "_gateway_mirror_token"
|
||||
_GATEWAY_MIRROR_RETAINED = "_gateway_mirror_retained"
|
||||
_GATEWAY_ENTRY_DATA_TOKEN_KEY = "_webui_mirror_token"
|
||||
_GATEWAY_AGENT_IDENTITY_V1 = "_gateway_agent_identity_v1"
|
||||
_gateway_relay_owners: dict[tuple[str, str], str] = {}
|
||||
|
||||
|
||||
def _approval_sse_subscribe(session_id: str) -> queue.Queue:
|
||||
@@ -106,7 +109,7 @@ def _approval_sse_notify(session_id: str, head: dict | None, total: int) -> None
|
||||
_approval_sse_notify_locked(session_id, head, total)
|
||||
|
||||
|
||||
def _gateway_mirror_entry_token(entry) -> str:
|
||||
def _gateway_mirror_entry_token(entry) -> str | None:
|
||||
"""Return a stable token for the current process lifetime of a gateway head.
|
||||
|
||||
Stamps a token key into the entry's `.data` dict so
|
||||
@@ -120,7 +123,7 @@ def _gateway_mirror_entry_token(entry) -> str:
|
||||
token = uuid.uuid4().hex
|
||||
data[_GATEWAY_ENTRY_DATA_TOKEN_KEY] = token
|
||||
return token
|
||||
return uuid.uuid4().hex
|
||||
return None
|
||||
|
||||
|
||||
def _is_gateway_mirror_entry(entry: dict | None) -> bool:
|
||||
@@ -147,29 +150,118 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
|
||||
live_head_entry = live_gateway_queue[0] if live_gateway_queue else None
|
||||
live_head_data = getattr(live_head_entry, "data", None) or {}
|
||||
live_token = _gateway_mirror_entry_token(live_head_entry) if live_head_entry and live_head_data else None
|
||||
has_no_run_mirror = any(
|
||||
_is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
for entry in queue_list
|
||||
)
|
||||
live_run_id = str(live_head_data.get("run_id") or "").strip()
|
||||
live_has_token = bool(live_head_data.get(_GATEWAY_ENTRY_DATA_TOKEN_KEY))
|
||||
live_local_tokens: set[str] = set()
|
||||
for live_entry in live_gateway_queue:
|
||||
live_data = getattr(live_entry, "data", None) or {}
|
||||
if str(live_data.get("run_id") or "").strip():
|
||||
continue
|
||||
live_entry_token = str(live_data.get(_GATEWAY_ENTRY_DATA_TOKEN_KEY) or "").strip()
|
||||
if live_entry_token or not has_no_run_mirror:
|
||||
live_entry_token = _gateway_mirror_entry_token(live_entry) or ""
|
||||
if live_entry_token:
|
||||
live_local_tokens.add(live_entry_token)
|
||||
if not str(live_data.get("approval_id") or "").strip():
|
||||
live_data["approval_id"] = f"gwlocal:{live_entry_token}"
|
||||
live_token = (
|
||||
_gateway_mirror_entry_token(live_head_entry)
|
||||
if live_head_entry and live_head_data
|
||||
and (live_run_id or live_has_token or not has_no_run_mirror)
|
||||
else None
|
||||
)
|
||||
if live_token and live_run_id and not str(live_head_data.get("approval_id") or "").strip():
|
||||
live_head_data["approval_id"] = f"gwrun:{live_run_id}:{live_token}"
|
||||
live_approval_id = str(live_head_data.get("approval_id") or "").strip()
|
||||
|
||||
rebuilt: list[dict] = []
|
||||
deferred_run_entries: list[dict] = []
|
||||
live_mirror_present = False
|
||||
for entry in queue_list:
|
||||
if not _is_gateway_mirror_entry(entry):
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
if live_token and entry.get(_GATEWAY_MIRROR_TOKEN) == live_token and not live_mirror_present:
|
||||
entry_run_id = str(entry.get("run_id") or "").strip()
|
||||
entry_approval_id = str(entry.get("approval_id") or "").strip()
|
||||
entry_token = str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
|
||||
matches_live_head = False
|
||||
if live_token:
|
||||
if entry_token and entry_token == live_token:
|
||||
matches_live_head = True
|
||||
elif (
|
||||
live_approval_id
|
||||
and live_run_id
|
||||
and entry_approval_id == live_approval_id
|
||||
and entry_run_id == live_run_id
|
||||
):
|
||||
matches_live_head = True
|
||||
|
||||
if entry_run_id:
|
||||
if matches_live_head and not live_mirror_present:
|
||||
if entry_token != live_token:
|
||||
entry[_GATEWAY_MIRROR_TOKEN] = live_token
|
||||
changed = True
|
||||
rebuilt.append(entry)
|
||||
live_mirror_present = True
|
||||
continue
|
||||
if live_token:
|
||||
if entry_token:
|
||||
changed = True
|
||||
continue
|
||||
deferred_run_entries.append(entry)
|
||||
continue
|
||||
if not entry_token:
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
changed = True
|
||||
continue
|
||||
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED):
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
|
||||
if matches_live_head and not live_mirror_present:
|
||||
if entry_token != live_token:
|
||||
entry[_GATEWAY_MIRROR_TOKEN] = live_token
|
||||
changed = True
|
||||
rebuilt.append(entry)
|
||||
live_mirror_present = True
|
||||
continue
|
||||
|
||||
if entry_token and entry_token in live_local_tokens:
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
|
||||
if not live_token:
|
||||
if entry_token:
|
||||
changed = True
|
||||
continue
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
|
||||
changed = True
|
||||
|
||||
if live_token and not live_mirror_present:
|
||||
mirror_entry = dict(live_head_data)
|
||||
mirror_entry.setdefault("approval_id", uuid.uuid4().hex)
|
||||
mirror_run_id = str(mirror_entry.get("run_id") or "").strip()
|
||||
mirror_entry.setdefault(
|
||||
"approval_id",
|
||||
f"gwrun:{mirror_run_id}:{live_token}" if mirror_run_id else uuid.uuid4().hex,
|
||||
)
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
mirror_entry[_GATEWAY_MIRROR_TOKEN] = live_token
|
||||
rebuilt.append(mirror_entry)
|
||||
live_mirror_present = True
|
||||
changed = True
|
||||
|
||||
if deferred_run_entries:
|
||||
rebuilt.extend(deferred_run_entries)
|
||||
|
||||
if rebuilt:
|
||||
if rebuilt != queue_list:
|
||||
_pending[session_key] = rebuilt
|
||||
@@ -184,38 +276,337 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
return head, total, changed
|
||||
|
||||
|
||||
def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str | None:
|
||||
"""Return the mirrored gateway approval run_id for a matching pending card.
|
||||
def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None:
|
||||
"""Return the exact live run-backed mirror under `_lock`."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
if approval_id:
|
||||
matched_entry: dict | None = None
|
||||
for entry in entries:
|
||||
if not _is_gateway_mirror_entry(entry):
|
||||
continue
|
||||
if entry.get("approval_id") != approval_id:
|
||||
continue
|
||||
entry_run_id = str(entry.get("run_id") or "").strip()
|
||||
if not entry_run_id:
|
||||
if not run_id:
|
||||
return None
|
||||
continue
|
||||
if run_id and entry_run_id != run_id:
|
||||
continue
|
||||
if run_id:
|
||||
return entry
|
||||
if matched_entry is not None:
|
||||
return None
|
||||
matched_entry = entry
|
||||
return matched_entry
|
||||
for entry in entries:
|
||||
if not _is_gateway_mirror_entry(entry) or not str(entry.get("run_id") or "").strip():
|
||||
continue
|
||||
if run_id and entry.get("run_id") == run_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
Reconciles the mirror first so a live gateway head still survives a lost
|
||||
`active_stream_id` pointer.
|
||||
"""
|
||||
|
||||
def gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None:
|
||||
"""Return an exact live run-backed mirror for this session."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
|
||||
return dict(entry) if entry else None
|
||||
|
||||
|
||||
def claim_gateway_approval_relay_owner(session_key: str, run_id: str, approval_id: str) -> bool:
|
||||
"""Claim the single-flight relay owner for one `(session, run)` pair."""
|
||||
session_key = str(session_key or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
approval_id = str(approval_id or "").strip()
|
||||
if not session_key or not run_id:
|
||||
return False
|
||||
with _lock:
|
||||
key = (session_key, run_id)
|
||||
if key in _gateway_relay_owners:
|
||||
return False
|
||||
_gateway_relay_owners[key] = approval_id
|
||||
return True
|
||||
|
||||
|
||||
def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval_id: str = "") -> None:
|
||||
"""Release the single-flight relay owner for one `(session, run)` pair."""
|
||||
session_key = str(session_key or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
approval_id = str(approval_id or "").strip()
|
||||
if not session_key or not run_id:
|
||||
return
|
||||
with _lock:
|
||||
key = (session_key, run_id)
|
||||
current = str(_gateway_relay_owners.get(key) or "").strip()
|
||||
if approval_id and current and current != approval_id:
|
||||
return
|
||||
_gateway_relay_owners.pop(key, None)
|
||||
|
||||
|
||||
def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> bool:
|
||||
"""Retire one approval, or every mirror for a terminal run."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
normalized_run_id = str(run_id or "").strip()
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = gateway_queue
|
||||
gateway_queue_changed = False
|
||||
if approval_id:
|
||||
match = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
|
||||
if match is None and not normalized_run_id:
|
||||
match = next((entry for entry in entries if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
and str(entry.get("approval_id") or "").strip() == approval_id), None)
|
||||
retired = [match] if match else []
|
||||
else:
|
||||
retired = [
|
||||
entry for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip() == normalized_run_id
|
||||
] if normalized_run_id else [
|
||||
entry for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
]
|
||||
if normalized_run_id:
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("run_id") or "").strip() == normalized_run_id:
|
||||
gateway_queue_changed = True
|
||||
continue
|
||||
retained_gateway_queue.append(entry)
|
||||
if not retired and not gateway_queue_changed:
|
||||
head, total, changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if changed:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return changed
|
||||
for match in retired:
|
||||
entries.remove(match)
|
||||
if normalized_run_id and not approval_id:
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
if entries:
|
||||
_pending[session_key] = entries
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return True
|
||||
|
||||
|
||||
def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str | None:
|
||||
"""Compatibility wrapper for exact run-backed lookup."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
if not approval_id:
|
||||
return None
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
queue = _pending.get(session_key)
|
||||
if isinstance(queue, list):
|
||||
entries = queue
|
||||
elif queue:
|
||||
entries = [queue]
|
||||
else:
|
||||
return None
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and entry.get("approval_id") == approval_id and entry.get(_GATEWAY_MIRROR_FLAG):
|
||||
run_id = str(entry.get("run_id") or "").strip()
|
||||
return run_id or None
|
||||
entry = _gateway_pending_mirror_locked(session_key, approval_id=approval_id)
|
||||
if entry:
|
||||
return str(entry.get("run_id") or "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def submit_gateway_pending_mirror(session_key: str, approval: dict) -> None:
|
||||
def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dict | None, int]:
|
||||
"""Mirror the live gateway head into WebUI polling state under a typed tag."""
|
||||
del approval # mirror from the live gateway head under `_lock`, not from callback input
|
||||
with _lock:
|
||||
run_id = str(approval.get("run_id") or "").strip()
|
||||
approval_id = str(approval.get("approval_id") or "").strip()
|
||||
live_gateway_queue = _gateway_queues.get(session_key) or []
|
||||
exact_local_entry = next(
|
||||
(
|
||||
entry for entry in live_gateway_queue
|
||||
if getattr(entry, "data", None) is approval
|
||||
),
|
||||
None,
|
||||
) if not run_id else None
|
||||
if exact_local_entry is None and not run_id and approval_id:
|
||||
exact_local_entry = next(
|
||||
(
|
||||
entry for entry in live_gateway_queue
|
||||
if str(((getattr(entry, "data", None) or {}).get("approval_id") or "")).strip() == approval_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_local_entry is not None:
|
||||
mirror_entries = _normalize_pending_queue_locked(session_key)
|
||||
entries_to_mirror = [live_gateway_queue[0]] if live_gateway_queue else []
|
||||
if exact_local_entry not in entries_to_mirror:
|
||||
entries_to_mirror.append(exact_local_entry)
|
||||
for entry in entries_to_mirror:
|
||||
local_data = entry.data
|
||||
token = _gateway_mirror_entry_token(entry)
|
||||
entry_approval_id = str(local_data.get("approval_id") or "").strip()
|
||||
if entry is exact_local_entry:
|
||||
entry_approval_id = approval_id or entry_approval_id or f"gwlocal:{token}"
|
||||
approval_id = entry_approval_id
|
||||
approval["approval_id"] = entry_approval_id
|
||||
elif not entry_approval_id:
|
||||
entry_approval_id = f"gwlocal:{token}"
|
||||
local_data["approval_id"] = entry_approval_id
|
||||
if not any(
|
||||
_is_gateway_mirror_entry(mirror)
|
||||
and str(mirror.get(_GATEWAY_MIRROR_TOKEN) or "") == token
|
||||
for mirror in mirror_entries
|
||||
):
|
||||
mirror_entry = dict(local_data)
|
||||
mirror_entry["approval_id"] = entry_approval_id
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
mirror_entry[_GATEWAY_MIRROR_TOKEN] = token
|
||||
mirror_entries.append(mirror_entry)
|
||||
if run_id:
|
||||
live_head_entry = live_gateway_queue[0] if live_gateway_queue else None
|
||||
live_head_data = getattr(live_head_entry, "data", None) or {}
|
||||
live_head_run_id = str(live_head_data.get("run_id") or "").strip()
|
||||
live_head_approval_id = str(live_head_data.get("approval_id") or "").strip()
|
||||
live_token = (
|
||||
_gateway_mirror_entry_token(live_head_entry)
|
||||
if live_head_entry and live_head_data
|
||||
else None
|
||||
)
|
||||
if (
|
||||
live_token
|
||||
and live_head_run_id == run_id
|
||||
and (
|
||||
not approval_id
|
||||
or not live_head_approval_id
|
||||
or live_head_approval_id == approval_id
|
||||
)
|
||||
):
|
||||
if approval_id:
|
||||
live_head_data["approval_id"] = approval_id
|
||||
else:
|
||||
approval_id = live_head_approval_id
|
||||
if not approval_id:
|
||||
approval_id = f"gwrun:{run_id}:{live_token}"
|
||||
live_head_data["approval_id"] = approval_id
|
||||
approval["approval_id"] = approval_id
|
||||
else:
|
||||
if not approval_id:
|
||||
approval_id = f"gwrun:{run_id}:{uuid.uuid4().hex}"
|
||||
approval["approval_id"] = approval_id
|
||||
mirror_entry = dict(approval)
|
||||
mirror_entry["run_id"] = run_id
|
||||
mirror_entry["approval_id"] = approval_id
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
if not _gateway_pending_mirror_locked(session_key, approval_id=approval_id, run_id=run_id):
|
||||
_normalize_pending_queue_locked(session_key).append(mirror_entry)
|
||||
elif not exact_local_entry:
|
||||
if not approval_id:
|
||||
approval_id = uuid.uuid4().hex
|
||||
approval["approval_id"] = approval_id
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
no_run_mirror = next(
|
||||
(
|
||||
entry for entry in reversed(entries)
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
and str(entry.get("approval_id") or "").strip() == approval_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if no_run_mirror:
|
||||
approval["approval_id"] = str(no_run_mirror.get("approval_id") or approval_id).strip()
|
||||
elif not _gateway_pending_mirror_locked(session_key, approval_id=approval_id):
|
||||
mirror_entry = dict(approval)
|
||||
mirror_entry["approval_id"] = approval_id
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
_normalize_pending_queue_locked(session_key).append(mirror_entry)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
publish_session_list_changed("attention_pending")
|
||||
return (dict(head) if head else None), total
|
||||
|
||||
|
||||
def resolve_gateway_pending_local(
|
||||
session_key: str, approval_id: str, choice: str, reason: str | None = None
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Resolve the exact parked local entry bound to an approval mirror."""
|
||||
target = None
|
||||
with _lock:
|
||||
approval_id = str(approval_id or "").strip()
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
for index, entry in enumerate(gateway_queue):
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("approval_id") or "").strip() == approval_id:
|
||||
target = gateway_queue.pop(index)
|
||||
break
|
||||
if gateway_queue:
|
||||
_gateway_queues[session_key] = gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if target is None:
|
||||
return 0, head, total
|
||||
target.result = choice
|
||||
if reason:
|
||||
target.reason = reason
|
||||
target.event.set()
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return 1, head, total
|
||||
|
||||
|
||||
def resolve_gateway_pending_local_no_run_mirror(
|
||||
session_key: str, approval_id: str, choice: str, reason: str | None = None
|
||||
) -> tuple[bool, int, dict | None, int]:
|
||||
"""Resolve an exact no-run mirror only while its parked producer still exists."""
|
||||
target = None
|
||||
with _lock:
|
||||
approval_id = str(approval_id or "").strip()
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
matched_mirror = next(
|
||||
(
|
||||
entry for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
and str(entry.get("approval_id") or "").strip() == approval_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matched_mirror is None:
|
||||
return False, 0, entries[0] if entries else None, len(entries)
|
||||
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
for index, entry in enumerate(gateway_queue):
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("approval_id") or "").strip() == approval_id:
|
||||
target = gateway_queue.pop(index)
|
||||
break
|
||||
if target is None:
|
||||
matched_mirror[_GATEWAY_MIRROR_RETAINED] = True
|
||||
return True, 0, entries[0] if entries else None, len(entries)
|
||||
|
||||
if gateway_queue:
|
||||
_gateway_queues[session_key] = gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
entries.remove(matched_mirror)
|
||||
if entries:
|
||||
_pending[session_key] = entries
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
target.result = choice
|
||||
if reason:
|
||||
target.reason = reason
|
||||
target.event.set()
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return True, 1, head, total
|
||||
|
||||
|
||||
def submit_pending(session_key: str, approval: dict) -> None:
|
||||
|
||||
+414
-64
@@ -2823,7 +2823,9 @@ from api.config import (
|
||||
set_reasoning_display,
|
||||
set_reasoning_effort,
|
||||
create_stream_channel,
|
||||
get_config,
|
||||
get_webui_session_save_mode,
|
||||
get_config_snapshot,
|
||||
STREAM_GOAL_RELATED,
|
||||
PENDING_GOAL_CONTINUATION,
|
||||
_get_config_path,
|
||||
@@ -3132,6 +3134,38 @@ def _run_journal_snapshot_merge_args(existing, incoming):
|
||||
return merged, changed
|
||||
|
||||
|
||||
def _run_journal_envelope_run_id_result(event: dict) -> tuple[str | None, bool]:
|
||||
raw_run_id = event.get("run_id")
|
||||
if raw_run_id is None:
|
||||
return None, False
|
||||
if not isinstance(raw_run_id, str):
|
||||
return None, True
|
||||
run_id = raw_run_id.strip()
|
||||
if not run_id:
|
||||
return None, True
|
||||
raw_event_id = event.get("event_id")
|
||||
event_id = str(raw_event_id or "").strip()
|
||||
if event_id:
|
||||
event_run_id, event_seq = _shared_parse_run_journal_event_id(event_id)
|
||||
if event_run_id and event_seq is not None and event_run_id != run_id:
|
||||
return None, True
|
||||
return run_id, False
|
||||
|
||||
|
||||
def _run_journal_snapshot_event_id_for_run(
|
||||
event: dict,
|
||||
run_id: str,
|
||||
event_seq: int,
|
||||
) -> str | None:
|
||||
raw_event_id = event.get("event_id")
|
||||
event_id = str(raw_event_id or "").strip()
|
||||
if event_id:
|
||||
event_run_id, parsed_seq = _shared_parse_run_journal_event_id(event_id)
|
||||
if event_run_id == run_id and parsed_seq is not None:
|
||||
return event_id
|
||||
return f"{run_id}:{event_seq}" if event_seq else None
|
||||
|
||||
|
||||
def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | None:
|
||||
stream_id = str(stream_id or "").strip()
|
||||
if not stream_id:
|
||||
@@ -3152,6 +3186,22 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
events = [event for event in (journal.get("events") or []) if isinstance(event, dict)]
|
||||
if not events:
|
||||
return None
|
||||
event_run_ids: set[str] = set()
|
||||
malformed_envelope_run_id = False
|
||||
for event in events:
|
||||
event_run_id, event_run_id_malformed = _run_journal_envelope_run_id_result(event)
|
||||
if event_run_id is not None:
|
||||
event_run_ids.add(event_run_id)
|
||||
if event_run_id_malformed:
|
||||
malformed_envelope_run_id = True
|
||||
# The event envelope is the durable identity authority. Older summaries
|
||||
# are keyed by the transport id, so only use that fallback when the journal
|
||||
# does not provide one unambiguous run id.
|
||||
run_id = (
|
||||
next(iter(event_run_ids))
|
||||
if not malformed_envelope_run_id and len(event_run_ids) == 1
|
||||
else str(summary.get("run_id") or stream_id).strip()
|
||||
)
|
||||
|
||||
assistant_text = ""
|
||||
reasoning_text = ""
|
||||
@@ -3346,7 +3396,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"source_event_type": "token",
|
||||
"event_id": None,
|
||||
"local_id": local_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
"status": status,
|
||||
@@ -3354,7 +3404,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"identity": {
|
||||
"event_id": None,
|
||||
"local_id": local_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
},
|
||||
@@ -3389,7 +3439,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"source_event_type": "reasoning",
|
||||
"event_id": None,
|
||||
"local_id": local_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
"status": status,
|
||||
@@ -3397,7 +3447,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"identity": {
|
||||
"event_id": None,
|
||||
"local_id": local_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
},
|
||||
@@ -3466,7 +3516,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"source_event_type": "tool_complete" if call.get("done") else "tool",
|
||||
"event_id": None,
|
||||
"local_id": tool_id or row_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
"status": status,
|
||||
@@ -3474,7 +3524,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"identity": {
|
||||
"event_id": None,
|
||||
"local_id": tool_id or row_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
},
|
||||
@@ -3588,7 +3638,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"source_event_type": "runtime_journal_snapshot",
|
||||
"event_id": None,
|
||||
"local_id": f"lifecycle:{stream_id}:running",
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
"status": "running",
|
||||
@@ -3596,7 +3646,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"identity": {
|
||||
"event_id": None,
|
||||
"local_id": f"lifecycle:{stream_id}:running",
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"stream_id": stream_id,
|
||||
"seq": None,
|
||||
},
|
||||
@@ -3626,9 +3676,11 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
event_last_seq = 0
|
||||
if event_last_seq >= summary_last_seq:
|
||||
last_seq = event_last_seq
|
||||
last_event_id = events[-1].get("event_id") or (
|
||||
f"{stream_id}:{event_last_seq}" if event_last_seq else summary.get("last_event_id")
|
||||
)
|
||||
last_event_id = _run_journal_snapshot_event_id_for_run(
|
||||
events[-1],
|
||||
run_id,
|
||||
event_last_seq,
|
||||
) or summary.get("last_event_id")
|
||||
else:
|
||||
last_seq = summary_last_seq
|
||||
last_event_id = summary.get("last_event_id") or events[-1].get("event_id")
|
||||
@@ -3656,7 +3708,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
"identity": {
|
||||
"session_id": session_id,
|
||||
"stream_id": stream_id,
|
||||
"run_id": stream_id,
|
||||
"run_id": run_id,
|
||||
"source_message_refs": [],
|
||||
},
|
||||
"lifecycle": {
|
||||
@@ -4515,7 +4567,17 @@ def _anchor_scene_row_has_live_identity(row) -> bool:
|
||||
values = [row.get("row_id"), row.get("local_id"), row.get("event_id")]
|
||||
identity = row.get("identity") if isinstance(row.get("identity"), dict) else {}
|
||||
values.extend([identity.get("local_id"), identity.get("event_id")])
|
||||
return any(str(value or "").startswith("live-") for value in values)
|
||||
if any(str(value or "").startswith("live-") for value in values):
|
||||
return True
|
||||
group = row.get("group") if isinstance(row.get("group"), dict) else {}
|
||||
has_stream_owner = bool(
|
||||
row.get("stream_id")
|
||||
or row.get("run_id")
|
||||
or identity.get("stream_id")
|
||||
or identity.get("run_id")
|
||||
)
|
||||
has_assistant_message_index = group.get("assistant_msg_idx") is not None
|
||||
return has_stream_owner and not has_assistant_message_index
|
||||
|
||||
|
||||
def _anchor_scene_settle_live_running_row(row, *, has_settled_thinking: bool):
|
||||
@@ -4532,6 +4594,14 @@ def _anchor_scene_settle_live_running_row(row, *, has_settled_thinking: bool):
|
||||
return None
|
||||
next_row = copy.deepcopy(row)
|
||||
next_row["status"] = "completed"
|
||||
payload = next_row.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
payload["status"] = "completed"
|
||||
if role == "tool":
|
||||
payload["done"] = True
|
||||
tool = next_row.get("tool")
|
||||
if role == "tool" and isinstance(tool, dict):
|
||||
tool["done"] = True
|
||||
return next_row
|
||||
|
||||
|
||||
@@ -5495,6 +5565,7 @@ def _extension_sidecar_proxy_request_headers(handler) -> dict[str, str]:
|
||||
lower in blocked_headers
|
||||
or lower in {"authorization", "cookie", "content-length", "host", "origin", "referer"}
|
||||
or lower.startswith("x-csrf")
|
||||
or lower.startswith("x-hermes-")
|
||||
):
|
||||
continue
|
||||
headers[str(name)] = str(value)
|
||||
@@ -5508,7 +5579,11 @@ def _send_extension_sidecar_proxy_response(handler, status: int, body: bytes, he
|
||||
if headers and hasattr(headers, "items"):
|
||||
for name, value in headers.items():
|
||||
lower = str(name).lower()
|
||||
if lower in blocked_headers or lower in {"content-length", "set-cookie"}:
|
||||
if (
|
||||
lower in blocked_headers
|
||||
or lower in {"content-length", "set-cookie"}
|
||||
or lower.startswith("x-hermes-")
|
||||
):
|
||||
continue
|
||||
if lower == "content-type":
|
||||
sent_content_type = True
|
||||
@@ -5605,10 +5680,17 @@ def _handle_extension_sidecar_proxy(
|
||||
proxy_path,
|
||||
query=parsed.query,
|
||||
)
|
||||
proxied_headers = _extension_sidecar_proxy_request_headers(handler)
|
||||
# token-v1: inject the per-extension shared secret core minted. The
|
||||
# inbound x-hermes-* strip above guarantees the client cannot have
|
||||
# forged this header.
|
||||
_auth_token = target.get("auth_token")
|
||||
if _auth_token:
|
||||
proxied_headers["X-Hermes-Sidecar-Token"] = _auth_token
|
||||
request = Request(
|
||||
target["upstream_url"],
|
||||
data=request_body,
|
||||
headers=_extension_sidecar_proxy_request_headers(handler),
|
||||
headers=proxied_headers,
|
||||
method=method,
|
||||
)
|
||||
opener = _extension_sidecar_proxy_same_origin_opener(target["origin"])
|
||||
@@ -9633,8 +9715,15 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp
|
||||
_approval_sse_notify_locked,
|
||||
_approval_sse_notify,
|
||||
_GATEWAY_MIRROR_FLAG,
|
||||
_gateway_mirrored_pending_run_id,
|
||||
_GATEWAY_MIRROR_TOKEN,
|
||||
_gateway_mirror_entry_token,
|
||||
claim_gateway_approval_relay_owner,
|
||||
gateway_pending_mirror,
|
||||
release_gateway_approval_relay_owner,
|
||||
retire_gateway_pending_mirror,
|
||||
reconcile_gateway_pending_mirror_locked,
|
||||
resolve_gateway_pending_local,
|
||||
resolve_gateway_pending_local_no_run_mirror,
|
||||
submit_gateway_pending_mirror,
|
||||
submit_pending,
|
||||
)
|
||||
@@ -13285,6 +13374,39 @@ def handle_get(handler, parsed) -> bool:
|
||||
return bad(handler, "stream_id required")
|
||||
if not _stream_id_visible_to_request_profile(handler, stream_id):
|
||||
return True
|
||||
gateway_stop_blocked = False
|
||||
try:
|
||||
from api.gateway_chat import (
|
||||
GATEWAY_RUN_ID_WAIT_TIMEOUT,
|
||||
stop_gateway_run,
|
||||
wait_for_gateway_run_id,
|
||||
)
|
||||
|
||||
structured_gateway, run_id = wait_for_gateway_run_id(stream_id, GATEWAY_RUN_ID_WAIT_TIMEOUT)
|
||||
if not run_id and structured_gateway:
|
||||
gateway_stop_blocked = True
|
||||
if run_id:
|
||||
if stop_gateway_run(run_id):
|
||||
owner_sid = stream_owner_session_id(stream_id)
|
||||
if owner_sid:
|
||||
retire_gateway_pending_mirror(owner_sid, run_id=run_id)
|
||||
else:
|
||||
gateway_stop_blocked = True
|
||||
except Exception:
|
||||
logger.debug("Failed to stop gateway run during chat cancellation", exc_info=True)
|
||||
gateway_stop_blocked = True
|
||||
if gateway_stop_blocked:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"cancelled": False,
|
||||
"stream_id": stream_id,
|
||||
"error": "Gateway stop failed",
|
||||
},
|
||||
status=502,
|
||||
)
|
||||
|
||||
from api.runtime_adapter import LegacyJournalRuntimeAdapter, runtime_adapter_enabled
|
||||
|
||||
if runtime_adapter_enabled():
|
||||
@@ -21124,13 +21246,27 @@ def _start_chat_stream_for_session(
|
||||
worker_kwargs = {"model_provider": model_provider, "goal_related": goal_related}
|
||||
if moa_config and not backend_is_gateway:
|
||||
worker_kwargs["moa_config"] = moa_config
|
||||
if backend_is_gateway:
|
||||
from api.gateway_chat import _mark_gateway_run_starting
|
||||
_mark_gateway_run_starting(stream_id)
|
||||
thr = threading.Thread(
|
||||
target=worker_target,
|
||||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||||
kwargs=worker_kwargs,
|
||||
daemon=True,
|
||||
)
|
||||
thr.start()
|
||||
try:
|
||||
thr.start()
|
||||
except Exception:
|
||||
if backend_is_gateway:
|
||||
try:
|
||||
from api.gateway_chat import _finish_gateway_run_starting
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
from api.gateway_chat import _clear_gateway_run_starting
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to record gateway run-start failure for stream %s", stream_id, exc_info=True)
|
||||
raise
|
||||
response = {
|
||||
"stream_id": stream_id,
|
||||
"session_id": s.session_id,
|
||||
@@ -21210,6 +21346,7 @@ def _start_run(
|
||||
route: str,
|
||||
diag=None,
|
||||
moa_config=None,
|
||||
gateway_chat_enabled: bool | None = None,
|
||||
):
|
||||
"""Shared start-run helper for /api/chat/start and start_session_turn.
|
||||
|
||||
@@ -21250,6 +21387,7 @@ def _start_run(
|
||||
diag=diag,
|
||||
source=request.source or source,
|
||||
moa_config=moa_config,
|
||||
external_runtime_owned=gateway_chat_enabled,
|
||||
)
|
||||
|
||||
def _legacy_adapter_factory():
|
||||
@@ -21290,7 +21428,7 @@ def _start_run(
|
||||
diag=diag,
|
||||
source=source,
|
||||
moa_config=moa_config,
|
||||
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
|
||||
external_runtime_owned=gateway_chat_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -22016,7 +22154,8 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
|
||||
explicit_model_pick = bool(body.get("explicit_model_pick"))
|
||||
moa_config = None
|
||||
gateway_chat_enabled = webui_gateway_chat_enabled(get_config())
|
||||
config_snapshot = get_config_snapshot()
|
||||
gateway_chat_enabled = webui_gateway_chat_enabled(config_snapshot)
|
||||
if body.get("moa_config"):
|
||||
if gateway_chat_enabled:
|
||||
return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409)
|
||||
@@ -22063,9 +22202,25 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
explicit_model_pick=explicit_model_pick,
|
||||
profile_provider=catalog_profile_provider,
|
||||
)
|
||||
if model_provider == "moa" and moa_config is None:
|
||||
if webui_gateway_chat_enabled(get_config()):
|
||||
if model_provider == "moa" and gateway_chat_enabled:
|
||||
from api.config import get_effective_default_model
|
||||
|
||||
model_config = config_snapshot.get("model") if isinstance(config_snapshot, dict) else None
|
||||
configured_default, configured_default_provider, configured_default_is_moa = (
|
||||
_moa_fast_path_model_state(get_effective_default_model(config_snapshot))
|
||||
)
|
||||
configured_provider = _clean_session_model_provider(
|
||||
model_config.get("provider") if isinstance(model_config, dict) else None
|
||||
)
|
||||
if configured_provider is None and configured_default_is_moa:
|
||||
configured_provider = configured_default_provider
|
||||
if (
|
||||
configured_provider != "moa"
|
||||
or model != configured_default
|
||||
or explicit_model_pick
|
||||
):
|
||||
return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409)
|
||||
elif model_provider == "moa" and moa_config is None:
|
||||
from api.commands import resolve_moa_config
|
||||
|
||||
try:
|
||||
@@ -22086,6 +22241,7 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
"source": "webui",
|
||||
"route": "/api/chat/start",
|
||||
"diag": diag,
|
||||
"gateway_chat_enabled": gateway_chat_enabled,
|
||||
}
|
||||
if not gateway_chat_enabled and moa_config is not None:
|
||||
start_run_kwargs["moa_config"] = moa_config
|
||||
@@ -22280,6 +22436,7 @@ def _handle_chat_sync(handler, body):
|
||||
_restore_display_reasoning_metadata,
|
||||
_restore_reasoning_metadata,
|
||||
_sanitize_messages_for_api,
|
||||
_compact_session_image_parts_for_persistence,
|
||||
_context_messages_for_new_turn,
|
||||
_workspace_context_prefix,
|
||||
)
|
||||
@@ -22357,6 +22514,7 @@ def _handle_chat_sync(handler, body):
|
||||
msg,
|
||||
source=getattr(s, "pending_user_source", None) or "webui",
|
||||
)
|
||||
_compact_session_image_parts_for_persistence(s)
|
||||
# Only auto-generate title when still default; preserves user renames
|
||||
if s.title == "Untitled":
|
||||
s.title = title_from(s.messages, s.title)
|
||||
@@ -23644,7 +23802,7 @@ def _handle_workspace_reorder(handler, body):
|
||||
return j(handler, {"ok": True, "workspaces": reordered})
|
||||
|
||||
|
||||
def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
|
||||
def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: str = "") -> bool:
|
||||
"""Resolve an approval through the existing callback path.
|
||||
|
||||
Slice 3b keeps the RuntimeAdapter as a protocol translator: it delegates to
|
||||
@@ -23655,17 +23813,31 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
|
||||
pending = None
|
||||
found_target = False
|
||||
gateway_keys = []
|
||||
local_gateway_approval_id = ""
|
||||
gateway_head_matches_target = False
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(sid)
|
||||
queue = _pending.get(sid)
|
||||
if isinstance(queue, list):
|
||||
if approval_id:
|
||||
# Find and remove the specific entry by approval_id.
|
||||
# Prefer a local exact-id match over a mirrored one, so a
|
||||
# preceding remote mirror cannot consume the user's local choice.
|
||||
preferred_index = None
|
||||
fallback_index = None
|
||||
for i, entry in enumerate(queue):
|
||||
if entry.get("approval_id") == approval_id:
|
||||
pending = queue.pop(i)
|
||||
found_target = True
|
||||
if entry.get("approval_id") != approval_id:
|
||||
continue
|
||||
if run_id and str(entry.get("run_id") or "").strip() != run_id:
|
||||
continue
|
||||
if not entry.get(_GATEWAY_MIRROR_FLAG) or not str(entry.get("run_id") or "").strip():
|
||||
preferred_index = i
|
||||
break
|
||||
if fallback_index is None:
|
||||
fallback_index = i
|
||||
match_index = preferred_index if preferred_index is not None else fallback_index
|
||||
if match_index is not None:
|
||||
pending = queue.pop(match_index)
|
||||
found_target = True
|
||||
else:
|
||||
# A stale explicit id must not accidentally approve the
|
||||
# oldest queued command; duplicate/stale responses are
|
||||
@@ -23678,7 +23850,13 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
|
||||
_pending.pop(sid, None)
|
||||
elif queue:
|
||||
# Legacy single-dict value.
|
||||
if not approval_id or queue.get("approval_id") == approval_id:
|
||||
if (
|
||||
not approval_id
|
||||
or (
|
||||
queue.get("approval_id") == approval_id
|
||||
and (not run_id or str(queue.get("run_id") or "").strip() == run_id)
|
||||
)
|
||||
):
|
||||
pending = _pending.pop(sid, None)
|
||||
found_target = pending is not None
|
||||
# When no _pending entry found AND no explicit approval_id was
|
||||
@@ -23701,16 +23879,35 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
|
||||
# idempotent over the session key set so the outcome is
|
||||
# the same regardless of which entry wins the race.
|
||||
found_target = True
|
||||
elif approval_id:
|
||||
gw_queue = _gateway_queues.get(sid)
|
||||
if gw_queue and len(gw_queue) > 0:
|
||||
gw_entry = gw_queue[0]
|
||||
gw_data = getattr(gw_entry, "data", None) or {}
|
||||
gw_approval_id = str(gw_data.get("approval_id") or "").strip()
|
||||
gw_run_id = str(gw_data.get("run_id") or "").strip()
|
||||
gateway_head_matches_target = bool(
|
||||
run_id and gw_approval_id == approval_id and gw_run_id == run_id
|
||||
)
|
||||
if gw_approval_id == approval_id and (not run_id or gw_run_id == run_id):
|
||||
local_gateway_approval_id = approval_id
|
||||
elif not run_id and found_target and pending:
|
||||
pending_token = str(pending.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
|
||||
gateway_token = str(gw_data.get("_webui_mirror_token") or "").strip()
|
||||
if pending_token and pending_token == gateway_token:
|
||||
gw_data["approval_id"] = approval_id
|
||||
local_gateway_approval_id = approval_id
|
||||
# Notify SSE subscribers of the new head (or empty state) so the UI
|
||||
# surfaces any trailing approvals that were queued behind this one
|
||||
# without waiting for the next submit_pending. Without this, a parallel
|
||||
# tool-call scenario (#527) would leave the second approval invisible
|
||||
# in the SSE path until the next event ever fired (the agent thread
|
||||
# would be parked indefinitely from the user's perspective).
|
||||
if isinstance(_pending.get(sid), list) and _pending[sid]:
|
||||
_approval_sse_notify_locked(sid, _pending[sid][0], len(_pending[sid]))
|
||||
else:
|
||||
_approval_sse_notify_locked(sid, None, 0)
|
||||
if not local_gateway_approval_id:
|
||||
if isinstance(_pending.get(sid), list) and _pending[sid]:
|
||||
_approval_sse_notify_locked(sid, _pending[sid][0], len(_pending[sid]))
|
||||
else:
|
||||
_approval_sse_notify_locked(sid, None, 0)
|
||||
|
||||
# Collect keys from both _pending and _gateway_queues
|
||||
keys_from_pending = pending.get("pattern_keys") or [pending.get("pattern_key", "")] if pending else []
|
||||
@@ -23732,11 +23929,18 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
|
||||
# This is the primary signal when streaming is active — the agent
|
||||
# thread is parked in entry.event.wait() and needs to be woken up.
|
||||
gateway_resolved = 0
|
||||
if found_target or not approval_id:
|
||||
local_gateway_resolved = 0
|
||||
if approval_id and found_target and not run_id and local_gateway_approval_id:
|
||||
local_gateway_resolved, _head, _total = resolve_gateway_pending_local(
|
||||
sid, local_gateway_approval_id, choice
|
||||
)
|
||||
elif approval_id and found_target and run_id and gateway_head_matches_target:
|
||||
gateway_resolved = resolve_gateway_approval(sid, choice, resolve_all=False) or 0
|
||||
elif not approval_id:
|
||||
gateway_resolved = resolve_gateway_approval(sid, choice, resolve_all=False) or 0
|
||||
# Keep the historical no-id response path truthy for old clients/tests while
|
||||
# making stale explicit ids bounded as not-active for Slice 3b.
|
||||
resolved = bool(pending) or bool(gateway_resolved) or not bool(approval_id)
|
||||
resolved = bool(pending) or bool(gateway_resolved) or bool(local_gateway_resolved) or not bool(approval_id)
|
||||
if resolved:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return resolved
|
||||
@@ -23746,6 +23950,10 @@ _GATEWAY_APPROVAL_RELAY_UNAVAILABLE = (
|
||||
"Gateway approval could not be relayed because the active run is unavailable. "
|
||||
"Reopen the session or retry after it reconnects."
|
||||
)
|
||||
_GATEWAY_APPROVAL_RELAY_IN_PROGRESS = (
|
||||
"Another approval response for this Gateway run is already in progress. "
|
||||
"Wait for it to finish, then retry if the card is still visible."
|
||||
)
|
||||
|
||||
|
||||
def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool:
|
||||
@@ -23761,11 +23969,11 @@ def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool
|
||||
if approval_id:
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and entry.get("approval_id") == approval_id:
|
||||
return bool(entry.get(_GATEWAY_MIRROR_FLAG))
|
||||
return bool(entry.get(_GATEWAY_MIRROR_FLAG)) and not str(entry.get("run_id") or "").strip()
|
||||
return False
|
||||
if not entries or not isinstance(entries[0], dict):
|
||||
return False
|
||||
return bool(entries[0].get(_GATEWAY_MIRROR_FLAG))
|
||||
return bool(entries[0].get(_GATEWAY_MIRROR_FLAG)) and not str(entries[0].get("run_id") or "").strip()
|
||||
|
||||
|
||||
def _session_has_pending_approval(sid: str) -> bool:
|
||||
@@ -23808,36 +24016,76 @@ def _handle_approval_respond(handler, body):
|
||||
_gateway_api_key,
|
||||
webui_gateway_chat_enabled,
|
||||
)
|
||||
from api.config import get_config as _get_config
|
||||
from api.config import get_config as _get_config, gateway_supports_approval_identity_v1
|
||||
from api.route_approvals import _GATEWAY_AGENT_IDENTITY_V1
|
||||
s = get_session(sid)
|
||||
_run_id = None
|
||||
_candidate_run_id = None
|
||||
if s is not None:
|
||||
active_sid = getattr(s, "active_stream_id", None)
|
||||
if active_sid:
|
||||
_run_id = _STREAM_RUN_IDS.get(active_sid)
|
||||
if not _run_id and approval_id:
|
||||
_run_id = _gateway_mirrored_pending_run_id(sid, approval_id)
|
||||
if _run_id:
|
||||
if not approval_id:
|
||||
return bad(handler, "approval_id is required for gateway approvals")
|
||||
from api.runner_client import HttpRunnerClient, RunnerClientError
|
||||
_cfg = _get_config()
|
||||
_base = _gateway_base_url(_cfg)
|
||||
_key = _gateway_api_key()
|
||||
try:
|
||||
HttpRunnerClient(base_url=_base, api_key=_key).respond_approval(_run_id, approval_id, choice)
|
||||
except (RunnerClientError, ValueError) as exc:
|
||||
return j(handler, {"ok": False, "choice": choice, "relayed": True, "error": str(exc)}, status=502)
|
||||
# The outbound relay only resumes the remote run; the local mirror
|
||||
# still needs the same cleanup path so the parked entry, mirrored
|
||||
# card, and agent signal all settle here too.
|
||||
_resolve_approval_legacy(sid, approval_id, choice)
|
||||
return j(handler, {"ok": True, "choice": choice, "relayed": True})
|
||||
# Only a still-mirrored gateway approval with a missing run should 409;
|
||||
# stale or empty gateway clicks fall through to local resolution.
|
||||
if webui_gateway_chat_enabled(_get_config()) and _gateway_pending_approval_without_run_id(
|
||||
sid, approval_id
|
||||
):
|
||||
_candidate_run_id = _STREAM_RUN_IDS.get(active_sid)
|
||||
local_match = False
|
||||
run_backed_gateway_matches = 0
|
||||
same_run_stale_without_token = False
|
||||
with _lock:
|
||||
queue = _pending.get(sid)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
if approval_id:
|
||||
local_match = any(
|
||||
isinstance(entry, dict)
|
||||
and entry.get("approval_id") == approval_id
|
||||
and (
|
||||
not entry.get(_GATEWAY_MIRROR_FLAG)
|
||||
or not str(entry.get("run_id") or "").strip()
|
||||
)
|
||||
for entry in entries
|
||||
)
|
||||
run_backed_gateway_matches = sum(
|
||||
1
|
||||
for entry in entries
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("approval_id") == approval_id
|
||||
and entry.get(_GATEWAY_MIRROR_FLAG)
|
||||
and str(entry.get("run_id") or "").strip()
|
||||
)
|
||||
gateway_queue = _gateway_queues.get(sid) or []
|
||||
live_head_data = getattr(gateway_queue[0], "data", None) or {} if gateway_queue else {}
|
||||
live_head_run_id = str(live_head_data.get("run_id") or "").strip()
|
||||
live_head_token = (
|
||||
_gateway_mirror_entry_token(gateway_queue[0])
|
||||
if gateway_queue and live_head_data
|
||||
else None
|
||||
)
|
||||
live_head_approval_id = str(live_head_data.get("approval_id") or "").strip()
|
||||
if not live_head_approval_id and live_head_token and live_head_run_id:
|
||||
live_head_approval_id = f"gwrun:{live_head_run_id}:{live_head_token}"
|
||||
stale_same_run_id = _candidate_run_id or live_head_run_id
|
||||
if (
|
||||
stale_same_run_id
|
||||
and live_head_run_id == stale_same_run_id
|
||||
and live_head_approval_id
|
||||
and live_head_approval_id != approval_id
|
||||
):
|
||||
same_run_stale_without_token = any(
|
||||
isinstance(entry, dict)
|
||||
and entry.get("approval_id") == approval_id
|
||||
and entry.get(_GATEWAY_MIRROR_FLAG)
|
||||
and str(entry.get("run_id") or "").strip() == stale_same_run_id
|
||||
and not str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
|
||||
for entry in entries
|
||||
)
|
||||
else:
|
||||
local_match = any(
|
||||
isinstance(entry, dict)
|
||||
and (
|
||||
not entry.get(_GATEWAY_MIRROR_FLAG)
|
||||
or not str(entry.get("run_id") or "").strip()
|
||||
)
|
||||
for entry in entries
|
||||
)
|
||||
if local_match:
|
||||
_candidate_run_id = None
|
||||
if approval_id and not local_match and same_run_stale_without_token:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
@@ -23849,6 +24097,111 @@ def _handle_approval_respond(handler, body):
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
matched_mirror = (
|
||||
gateway_pending_mirror(sid, approval_id=approval_id, run_id=_candidate_run_id)
|
||||
if approval_id and not local_match
|
||||
else None
|
||||
)
|
||||
_run_id = matched_mirror["run_id"] if matched_mirror else None
|
||||
if not matched_mirror and approval_id:
|
||||
if local_match:
|
||||
_candidate_run_id = None
|
||||
elif run_backed_gateway_matches > 1 and not _candidate_run_id:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
if _run_id:
|
||||
from api.runner_client import HttpRunnerClient, RunnerClientError
|
||||
_cfg = _get_config()
|
||||
_base = _gateway_base_url(_cfg)
|
||||
_key = _gateway_api_key()
|
||||
claimed_approval_id = str(matched_mirror.get("approval_id") or "").strip()
|
||||
if not claim_gateway_approval_relay_owner(sid, _run_id, claimed_approval_id):
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_approval_in_progress",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_IN_PROGRESS,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
try:
|
||||
current_mirror = gateway_pending_mirror(
|
||||
sid,
|
||||
approval_id=claimed_approval_id,
|
||||
run_id=_run_id,
|
||||
)
|
||||
if not current_mirror:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and gateway_supports_approval_identity_v1(_base, _key)
|
||||
if not identity_v1:
|
||||
run_head = gateway_pending_mirror(sid, run_id=_run_id)
|
||||
if not run_head or str(run_head.get("approval_id") or "").strip() != claimed_approval_id:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
matched_mirror = current_mirror
|
||||
try:
|
||||
HttpRunnerClient(base_url=_base, api_key=_key).respond_approval(
|
||||
_run_id, matched_mirror["approval_id"] if identity_v1 else "", choice
|
||||
)
|
||||
except (RunnerClientError, ValueError) as exc:
|
||||
return j(handler, {"ok": False, "choice": choice, "relayed": True, "error": str(exc)}, status=502)
|
||||
# The outbound relay only resumes the remote run; the local mirror
|
||||
# still needs the same cleanup path so the parked entry, mirrored
|
||||
# card, and agent signal all settle here too.
|
||||
cleanup_approval_id = matched_mirror["approval_id"]
|
||||
_resolve_approval_legacy(sid, cleanup_approval_id, choice, run_id=_run_id)
|
||||
retire_gateway_pending_mirror(sid, approval_id=cleanup_approval_id, run_id=_run_id)
|
||||
return j(handler, {"ok": True, "choice": choice, "relayed": True})
|
||||
finally:
|
||||
release_gateway_approval_relay_owner(sid, _run_id, claimed_approval_id)
|
||||
if _candidate_run_id:
|
||||
return j(handler, {"ok": False, "choice": choice, "relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409)
|
||||
# A no-run mirror is local visibility state only. Resolve it only while
|
||||
# the exact parked producer still exists; otherwise keep the card live
|
||||
# and fail closed instead of claiming success.
|
||||
if webui_gateway_chat_enabled(_get_config()):
|
||||
handled_no_run_mirror, resolved_count, _, _ = resolve_gateway_pending_local_no_run_mirror(
|
||||
sid, approval_id, choice
|
||||
)
|
||||
if handled_no_run_mirror and resolved_count == 1:
|
||||
return j(handler, {"ok": True, "choice": choice, "local_retired": True})
|
||||
if handled_no_run_mirror:
|
||||
return j(handler, {"ok": False, "choice": choice, "relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409)
|
||||
except Exception:
|
||||
pass # fall through to local approval path
|
||||
|
||||
@@ -25694,9 +26047,6 @@ def _handle_session_import(handler, body):
|
||||
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
|
||||
# ── MCP Server helpers ──
|
||||
from api.config import get_config, _save_yaml_config_file, _get_config_path, reload_config
|
||||
|
||||
def _mask_secrets(obj):
|
||||
"""Mask sensitive values in env vars and headers."""
|
||||
if not isinstance(obj, dict):
|
||||
|
||||
+92
-6
@@ -10,6 +10,7 @@ import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -27,6 +28,14 @@ _WRITER_LOCKS_GUARD = threading.Lock()
|
||||
# ``_reserve_next_seq`` and ``delete_run_journal`` (which evicts stale entries).
|
||||
_SEQ_CACHE: dict[str, int] = {}
|
||||
_SEQ_CACHE_LOCK = threading.Lock()
|
||||
# Summary callers only need terminal state and the latest cursor. Re-parsing a
|
||||
# completed journal's full payload (which can include multi-megabyte tool or
|
||||
# session results) on every status/reconnect probe is needless. This process
|
||||
# cache is keyed by a complete stat identity, so it is never used after an
|
||||
# atomic replacement, append, truncate, or same-path file recreation.
|
||||
_SUMMARY_CACHE_MAX_ENTRIES = 128
|
||||
_SUMMARY_CACHE: OrderedDict[str, tuple[tuple[int, int, int, int, int], dict]] = OrderedDict()
|
||||
_SUMMARY_CACHE_LOCK = threading.Lock()
|
||||
_TERMINAL_SSE_EVENTS = {"done", "cancel", "apperror", "error", "stream_end"}
|
||||
_FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC"
|
||||
_FSYNC_MODE_EAGER = "eager"
|
||||
@@ -71,6 +80,68 @@ def _lock_for(path: Path) -> threading.Lock:
|
||||
return lock
|
||||
|
||||
|
||||
def _summary_cache_signature(path: Path) -> tuple[int, int, int, int, int] | None:
|
||||
"""Return the complete filesystem identity used for summary-cache validity.
|
||||
|
||||
Includes ``st_ctime_ns`` so a same-inode, same-size rewrite that restores the
|
||||
original ``mtime_ns`` (e.g. an atomic replace) still invalidates the cache —
|
||||
ctime advances on any metadata/content change and cannot be forged back.
|
||||
"""
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return (
|
||||
int(stat.st_dev),
|
||||
int(stat.st_ino),
|
||||
int(stat.st_size),
|
||||
int(stat.st_mtime_ns),
|
||||
int(stat.st_ctime_ns),
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_summary(path: Path) -> dict | None:
|
||||
signature = _summary_cache_signature(path)
|
||||
if signature is None:
|
||||
return None
|
||||
key = str(path)
|
||||
with _SUMMARY_CACHE_LOCK:
|
||||
cached = _SUMMARY_CACHE.get(key)
|
||||
if cached is None:
|
||||
return None
|
||||
cached_signature, summary = cached
|
||||
if cached_signature != signature:
|
||||
_SUMMARY_CACHE.pop(key, None)
|
||||
return None
|
||||
_SUMMARY_CACHE.move_to_end(key)
|
||||
return dict(summary)
|
||||
|
||||
|
||||
def _cache_summary(
|
||||
path: Path,
|
||||
summary: dict,
|
||||
*,
|
||||
expected_signature: tuple[int, int, int, int, int] | None = None,
|
||||
) -> None:
|
||||
signature = _summary_cache_signature(path)
|
||||
# The pre-read signature is an enforced TOCTOU precondition. In particular,
|
||||
# a journal created after a missing-file read has ``None -> signature`` and
|
||||
# must not cache the empty/unknown result under the new file's identity.
|
||||
if signature is None or signature != expected_signature:
|
||||
return
|
||||
key = str(path)
|
||||
with _SUMMARY_CACHE_LOCK:
|
||||
_SUMMARY_CACHE[key] = (signature, dict(summary))
|
||||
_SUMMARY_CACHE.move_to_end(key)
|
||||
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
|
||||
_SUMMARY_CACHE.popitem(last=False)
|
||||
|
||||
|
||||
def _discard_cached_summary(path: Path) -> None:
|
||||
with _SUMMARY_CACHE_LOCK:
|
||||
_SUMMARY_CACHE.pop(str(path), None)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]:
|
||||
events: list[dict] = []
|
||||
malformed: list[dict] = []
|
||||
@@ -351,6 +422,7 @@ def append_run_event(
|
||||
fh.flush()
|
||||
if _should_fsync_event(terminal_state):
|
||||
os.fsync(fh.fileno())
|
||||
_discard_cached_summary(path)
|
||||
if created_file:
|
||||
_fsync_parent_dir(path)
|
||||
return event
|
||||
@@ -427,8 +499,15 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -
|
||||
|
||||
|
||||
def latest_run_summary(session_id: str, run_id: str, *, session_dir: Path | None = None) -> dict:
|
||||
journal = read_run_events(session_id, run_id, session_dir=session_dir)
|
||||
return _summary_from_events(session_id, run_id, journal.get("events") or [])
|
||||
path = _run_path(session_id, run_id, session_dir=session_dir)
|
||||
cached = _get_cached_summary(path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
pre_read_signature = _summary_cache_signature(path)
|
||||
events, _malformed = _read_jsonl(path)
|
||||
summary = _summary_from_events(session_id, run_id, events)
|
||||
_cache_summary(path, summary, expected_signature=pre_read_signature)
|
||||
return summary
|
||||
|
||||
|
||||
def session_journal_fingerprint(session_id: str, *, session_dir: Path | None = None) -> tuple[int, float, int]:
|
||||
@@ -471,8 +550,12 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict |
|
||||
journal_root = root / RUN_JOURNAL_DIR_NAME
|
||||
for path in journal_root.glob(f"*/{rid}.jsonl"):
|
||||
session_id = path.parent.name
|
||||
events, _malformed = _read_jsonl(path)
|
||||
summary = _summary_from_events(session_id, rid, events)
|
||||
summary = _get_cached_summary(path)
|
||||
if summary is None:
|
||||
pre_read_signature = _summary_cache_signature(path)
|
||||
events, _malformed = _read_jsonl(path)
|
||||
summary = _summary_from_events(session_id, rid, events)
|
||||
_cache_summary(path, summary, expected_signature=pre_read_signature)
|
||||
summary["path"] = str(path)
|
||||
return summary
|
||||
return None
|
||||
@@ -640,8 +723,11 @@ def delete_run_journal(session_id: str, *, session_dir: Path | None = None) -> b
|
||||
# ``_note_assigned_seq`` take — so a concurrent append on another path
|
||||
# cannot mutate the dict mid-iteration (``dictionary changed size``).
|
||||
with _SEQ_CACHE_LOCK:
|
||||
for key in [k for k in _SEQ_CACHE if str(Path(k).parent) == dir_key]:
|
||||
del _SEQ_CACHE[key]
|
||||
for cache_key in [entry for entry in _SEQ_CACHE if str(Path(entry).parent) == dir_key]:
|
||||
del _SEQ_CACHE[cache_key]
|
||||
with _SUMMARY_CACHE_LOCK:
|
||||
for cache_key in [entry for entry in _SUMMARY_CACHE if str(Path(entry).parent) == dir_key]:
|
||||
del _SUMMARY_CACHE[cache_key]
|
||||
return removed
|
||||
|
||||
|
||||
|
||||
+257
-3
@@ -8,13 +8,19 @@ links do not leak local workspace paths, profile details, or raw tool payloads.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import STATE_DIR
|
||||
@@ -109,7 +115,234 @@ def _redact_share_paths(text: str, extra_paths) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _sanitize_message(message: dict, *, redact_paths=()) -> dict | None:
|
||||
# Regex matching local MEDIA:<path> references — same pattern that
|
||||
# _inlineMediaHtmlForRef() in ui.js handles when rendering messages.
|
||||
# Excludes MEDIA: followed by http/https URLs so external images pass
|
||||
# through unchanged. file:// references are NOT matched here — they are
|
||||
# always rejected at the public-share boundary (absolute, un-scoped).
|
||||
_SHARE_MEDIA_RE = re.compile(
|
||||
r"MEDIA:(?!https?://)([^\s\)\]>]+)"
|
||||
)
|
||||
|
||||
# Max size (in bytes) for files we'll embed as base64 in a share snapshot.
|
||||
_SHARE_EMBED_MAX_BYTES = 512 * 1024 # 512 KiB
|
||||
|
||||
# Only these image MIME types may be embedded in public shares.
|
||||
# Non-image files and SVG are NEVER embedded — embedding arbitrary file
|
||||
# bytes circumvents the credential-redaction boundary that protects
|
||||
# message prose, and a public share is not a file-transfer service.
|
||||
# SVG is excluded because it is the only text-bearing type in this set;
|
||||
# agent-authored SVGs can carry credentials in their text content which
|
||||
# _redact_share_paths (which only touches message prose, not embedded
|
||||
# bytes) cannot reach.
|
||||
_SHARE_ALLOWED_MIME_TYPES: frozenset[str] = frozenset({
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
})
|
||||
|
||||
# SVG namespace URI used during sanitisation.
|
||||
_SVG_NS = "http://www.w3.org/2000/svg"
|
||||
|
||||
# Pattern matching on* event-handler attributes.
|
||||
_ON_ATTR_RE = re.compile(r"^on\w+$", re.IGNORECASE)
|
||||
|
||||
# Dangerous href/xlink:href schemes.
|
||||
_DANGEROUS_HREF_RE = re.compile(r"^\s*javascript\s*:", re.IGNORECASE)
|
||||
|
||||
# Static placeholder emitted when a media reference cannot be embedded.
|
||||
_PLACEHOLDER = "[*Local attachment omitted from public share*]"
|
||||
|
||||
# Magic byte signatures for allowed image formats — content-based validation
|
||||
# that catches mismatched extensions (e.g. a .png that is actually a script).
|
||||
# SVG is excluded here because it is validated by XML parsing in
|
||||
# _sanitize_svg_bytes.
|
||||
_IMAGE_MAGIC: dict[str, bytes] = {
|
||||
"image/png": b"\x89PNG\r\n\x1a\n",
|
||||
"image/jpeg": b"\xff\xd8\xff",
|
||||
"image/gif": b"GIF8",
|
||||
"image/webp": b"RIFF",
|
||||
}
|
||||
# Offset for WebP magic: "RIFF" at 0, file size at 4, "WEBP" at 8.
|
||||
_WEBP_MAGIC_OFFSET = 8
|
||||
_WEBP_MAGIC = b"WEBP"
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes, mime_type: str) -> bool:
|
||||
"""Verify *data* header bytes match the expected magic for *mime_type*.
|
||||
|
||||
Returns ``True`` if the content is consistent with the claimed type.
|
||||
SVG is exempt because it is validated structurally by
|
||||
:func:`_sanitize_svg_bytes`.
|
||||
"""
|
||||
if mime_type == "image/svg+xml":
|
||||
return True
|
||||
magic = _IMAGE_MAGIC.get(mime_type)
|
||||
if magic is None:
|
||||
return False
|
||||
if not data.startswith(magic):
|
||||
return False
|
||||
# Extra check for WebP: "WEBP" at offset 8.
|
||||
if mime_type == "image/webp":
|
||||
if len(data) < 12 or data[_WEBP_MAGIC_OFFSET:_WEBP_MAGIC_OFFSET + 4] != _WEBP_MAGIC:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _sanitize_svg_bytes(data: bytes) -> bytes:
|
||||
"""Strip script elements, on* handlers, and javascript: hrefs from SVG.
|
||||
|
||||
SVG images served via ``<img src="data:image/svg+xml;base64,…">`` are
|
||||
sandboxed by modern browsers and script execution is blocked. However,
|
||||
a sufficiently determined adversary with an older or exotic client may
|
||||
still extract credentials embedded in the SVG, so we strip the unsafe
|
||||
content at the server before it ever reaches a share page.
|
||||
|
||||
Returns sanitised SVG bytes on success, or the original *data* unchanged
|
||||
if the content cannot be parsed as XML (fail-closed).
|
||||
"""
|
||||
try:
|
||||
ET.register_namespace("", _SVG_NS)
|
||||
root = ET.fromstring(data.decode("utf-8", errors="replace"))
|
||||
except ET.ParseError:
|
||||
# Not valid XML — cannot sanitise safely. Return a minimal empty SVG
|
||||
# so the <img> renders nothing rather than embedding un-sanitised bytes.
|
||||
return b'<svg xmlns="http://www.w3.org/2000/svg"/>'
|
||||
|
||||
# Walk the tree depth-first, stripping on* attrs, dangerous hrefs,
|
||||
# and removing <script> children.
|
||||
def _walk(elem: ET.Element) -> None:
|
||||
for attr_name in list(elem.attrib):
|
||||
if _ON_ATTR_RE.match(attr_name):
|
||||
del elem.attrib[attr_name]
|
||||
elif attr_name in ("href", "xlink:href", "{http://www.w3.org/1999/xlink}href"):
|
||||
val = elem.attrib[attr_name]
|
||||
if _DANGEROUS_HREF_RE.match(val):
|
||||
del elem.attrib[attr_name]
|
||||
|
||||
for child in list(elem):
|
||||
tag = child.tag.split("}", 1)[-1] if "}" in child.tag else child.tag
|
||||
if tag == "script":
|
||||
elem.remove(child)
|
||||
else:
|
||||
_walk(child)
|
||||
|
||||
_walk(root)
|
||||
|
||||
buf = io.BytesIO()
|
||||
tree = ET.ElementTree(root)
|
||||
tree.write(buf, encoding="utf-8", xml_declaration=False)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _embed_share_media(text: str, *, allowed_roots: tuple[Path, ...] = ()) -> str:
|
||||
"""Find local MEDIA: references and replace them with inline <img> tags.
|
||||
|
||||
Only relative paths that resolve inside at least one of *allowed_roots*
|
||||
are honoured. Absolute paths, ``file://`` URIs, paths that traverse
|
||||
outside the allowed directories via ``..`` or symlinks, non-image MIME
|
||||
types, and files larger than ``_SHARE_EMBED_MAX_BYTES`` are all replaced
|
||||
with a static placeholder — no file content leaves the server.
|
||||
|
||||
This runs BEFORE :func:`_redact_share_paths` so the concrete file path
|
||||
is still available for the allowed-roots check.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
|
||||
allowed = tuple(Path(r).resolve() for r in allowed_roots if r)
|
||||
|
||||
def _resolve_against_roots(raw: str) -> Path | None:
|
||||
"""Resolve *raw* against each allowed root, returning the first valid
|
||||
absolute Path that lives inside one of them, or ``None``.
|
||||
|
||||
- ``file://`` is always rejected (absolute, un-scoped).
|
||||
- Absolute paths (``/…``, ``~…``) are resolved as-is and checked
|
||||
against the allowed-roots allow-list via ``is_relative_to()``.
|
||||
- Relative paths are joined with each allowed root in turn so they
|
||||
don't silently anchor to the server's process CWD.
|
||||
"""
|
||||
if raw.startswith("file://"):
|
||||
return None
|
||||
|
||||
# --- Absolute paths: resolve as-is, then allow-list check ------------
|
||||
if raw.startswith("/") or raw.startswith("~"):
|
||||
try:
|
||||
p = Path(raw).expanduser().resolve(strict=False)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
return None
|
||||
if not allowed or not any(p.is_relative_to(r) for r in allowed):
|
||||
return None
|
||||
return p if p.is_file() else None
|
||||
|
||||
# --- Relative paths: try each allowed root as the anchor -------------
|
||||
for root in allowed:
|
||||
try:
|
||||
candidate = (root / raw).resolve(strict=False)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
# Path traversal guard: resolved path must still be under the root.
|
||||
if not candidate.is_relative_to(root):
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _replace_ref(m: re.Match) -> str:
|
||||
raw = (m.group(1) or "").strip()
|
||||
if not raw:
|
||||
return m.group(0)
|
||||
|
||||
# --- Resolve and validate against allowed roots -----------------------
|
||||
p = _resolve_against_roots(raw)
|
||||
if p is None:
|
||||
return _PLACEHOLDER
|
||||
|
||||
# --- Size guard -------------------------------------------------------
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
except OSError:
|
||||
return _PLACEHOLDER
|
||||
|
||||
if size > _SHARE_EMBED_MAX_BYTES:
|
||||
return _PLACEHOLDER
|
||||
|
||||
# --- MIME allow-list (images only) ------------------------------------
|
||||
mime_type, _ = mimetypes.guess_type(str(p))
|
||||
if not mime_type or mime_type not in _SHARE_ALLOWED_MIME_TYPES:
|
||||
return _PLACEHOLDER
|
||||
|
||||
# --- Embed as base64 <img> -------------------------------------------
|
||||
try:
|
||||
data = p.read_bytes()
|
||||
# Content-based MIME validation: verify the actual file header
|
||||
# matches the claimed MIME type — catches extension-spoofed files
|
||||
# (e.g. a script renamed to .png).
|
||||
if not _check_image_magic(data, mime_type):
|
||||
return _PLACEHOLDER
|
||||
# Sanitise SVG content before embedding — SVG can carry
|
||||
# <script> elements and on* event handlers that could leak
|
||||
# credentials in the context of a public share page.
|
||||
if mime_type == "image/svg+xml":
|
||||
data = _sanitize_svg_bytes(data)
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
# HTML-escape the filename so a crafted name like
|
||||
# '"><script>alert(1)</script>' cannot break out of the
|
||||
# attribute and inject script into the share page.
|
||||
safe_name = html.escape(p.name, quote=True)
|
||||
return (
|
||||
f'<img src="data:{mime_type};base64,{b64}"'
|
||||
f' class="msg-media-img" alt="{safe_name}"'
|
||||
f' loading="lazy">'
|
||||
)
|
||||
except (OSError, MemoryError):
|
||||
return _PLACEHOLDER
|
||||
|
||||
return _SHARE_MEDIA_RE.sub(_replace_ref, text)
|
||||
|
||||
|
||||
def _sanitize_message(message: dict, *, redact_paths=(), allowed_roots: tuple[Path, ...] = ()) -> dict | None:
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
role = str(message.get("role") or "").strip().lower()
|
||||
@@ -119,8 +352,13 @@ def _sanitize_message(message: dict, *, redact_paths=()) -> dict | None:
|
||||
if not text:
|
||||
return None
|
||||
# ALWAYS-ON hardening for the public boundary, independent of any setting:
|
||||
# (1) force credential redaction, (2) strip known local paths.
|
||||
# (1) force credential redaction, (2) embed allowed local media,
|
||||
# (3) strip known local paths.
|
||||
text = _force_redact_credentials(text)
|
||||
# Embed local media BEFORE path redaction so the concrete path is still
|
||||
# available for file reads. MEDIA: references become self-contained data
|
||||
# URIs — or a static placeholder if the path is outside the allowed roots.
|
||||
text = _embed_share_media(text, allowed_roots=allowed_roots)
|
||||
text = _redact_share_paths(text, redact_paths)
|
||||
if not text.strip():
|
||||
return None
|
||||
@@ -174,9 +412,25 @@ def build_share_snapshot(session) -> dict:
|
||||
redact_paths.append(str(Path.home()))
|
||||
except Exception:
|
||||
pass
|
||||
# Collect allowed roots for _embed_share_media: only files inside the
|
||||
# session workspace or the attachments root may be embedded. This is
|
||||
# the hard security boundary that prevents arbitrary file reads through
|
||||
# crafted MEDIA: references in message text.
|
||||
_allowed_roots: list[Path] = []
|
||||
_ws = raw_dict.get("workspace")
|
||||
if _ws and isinstance(_ws, str) and _ws.strip():
|
||||
_allowed_roots.append(Path(_ws.strip()))
|
||||
try:
|
||||
from api.upload import _attachment_root
|
||||
_allowed_roots.append(_attachment_root())
|
||||
except Exception:
|
||||
pass
|
||||
_allowed_roots_tuple: tuple[Path, ...] = tuple(_allowed_roots)
|
||||
safe_messages = []
|
||||
for raw in safe_session.get("messages") or []:
|
||||
sanitized = _sanitize_message(raw, redact_paths=redact_paths)
|
||||
sanitized = _sanitize_message(
|
||||
raw, redact_paths=redact_paths, allowed_roots=_allowed_roots_tuple,
|
||||
)
|
||||
if sanitized:
|
||||
safe_messages.append(sanitized)
|
||||
if not safe_messages:
|
||||
|
||||
+251
-52
@@ -7,6 +7,7 @@ import contextlib
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import queue
|
||||
@@ -1831,6 +1832,77 @@ def _build_agent_thread_env(profile_runtime_env: dict | None, workspace: str, se
|
||||
return env
|
||||
|
||||
|
||||
_streaming_hermes_home_override_available = None
|
||||
|
||||
|
||||
def _resolve_streaming_hermes_home_override():
|
||||
"""Return hermes_constants module if context-local home override APIs exist.
|
||||
|
||||
Cached import-safe resolver mirrors the optional pattern used by
|
||||
`api.profiles` so older agent versions safely degrade to the process-global
|
||||
env mirror fallback.
|
||||
"""
|
||||
global _streaming_hermes_home_override_available
|
||||
import sys as _sys
|
||||
|
||||
if _streaming_hermes_home_override_available is False:
|
||||
return None
|
||||
|
||||
mod = _sys.modules.get('hermes_constants')
|
||||
if mod is None and _streaming_hermes_home_override_available is None:
|
||||
try:
|
||||
import hermes_constants # noqa: F401
|
||||
mod = _sys.modules.get('hermes_constants')
|
||||
except Exception:
|
||||
_streaming_hermes_home_override_available = False
|
||||
return None
|
||||
|
||||
if (
|
||||
mod is not None
|
||||
and hasattr(mod, 'set_hermes_home_override')
|
||||
and hasattr(mod, 'reset_hermes_home_override')
|
||||
):
|
||||
_streaming_hermes_home_override_available = True
|
||||
return mod
|
||||
|
||||
_streaming_hermes_home_override_available = False
|
||||
return None
|
||||
|
||||
|
||||
def _set_streaming_hermes_home_override(profile_home: str):
|
||||
"""Install the context-local home override if available.
|
||||
|
||||
Returns ``(module, token, installed)`` so callers can restore it with the
|
||||
matching module in the inverse order.
|
||||
"""
|
||||
if not profile_home:
|
||||
return None, None, False
|
||||
|
||||
_home_override_mod = _resolve_streaming_hermes_home_override()
|
||||
if _home_override_mod is None:
|
||||
return None, None, False
|
||||
|
||||
try:
|
||||
_token = _home_override_mod.set_hermes_home_override(profile_home)
|
||||
return _home_override_mod, _token, True
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to set streaming Hermes home override; continuing with os.environ mirror",
|
||||
exc_info=True,
|
||||
)
|
||||
return None, None, False
|
||||
|
||||
|
||||
def _reset_streaming_hermes_home_override(override_mod, override_token, override_installed: bool) -> None:
|
||||
"""Reset the context-local home override if it was installed."""
|
||||
if override_mod is None or not override_installed:
|
||||
return
|
||||
try:
|
||||
override_mod.reset_hermes_home_override(override_token)
|
||||
except Exception:
|
||||
logger.debug("Failed to reset streaming Hermes home override", exc_info=True)
|
||||
|
||||
|
||||
# ── Per-turn session identity (xsession wakeup misroute root fix — Option 1) ─
|
||||
# WebUI bound per-turn session identity ONLY to the process-global
|
||||
# os.environ['HERMES_SESSION_KEY'] (turn-start, line ~3263) and released the
|
||||
@@ -4225,6 +4297,76 @@ def _should_strip_reasoning_content(
|
||||
return False
|
||||
|
||||
|
||||
def _compact_image_parts_for_persistence(messages) -> int:
|
||||
"""Replace persisted image parts with text placeholders after a completed turn.
|
||||
|
||||
The active model receives native image parts while a tool call is running. Once
|
||||
the turn has completed, retaining base64 data URLs in both the visible
|
||||
transcript and ``context_messages`` makes every JSON sidecar save/load and
|
||||
session API response scale with the image bytes. Hermes Agent's durable
|
||||
session store applies the same text-only policy for completed multimodal
|
||||
tool results. Keep text parts and the surrounding tool-call chain intact so
|
||||
future turns retain the conversational record and can re-open the original
|
||||
image from the preceding tool-call arguments when needed.
|
||||
|
||||
This intentionally mutates the owned session message rows in place. It is
|
||||
called only after ``run_conversation()`` has returned, so it never removes
|
||||
image parts that the current model invocation still needs.
|
||||
"""
|
||||
changed = 0
|
||||
for message in messages or ():
|
||||
# Mirror Hermes Agent's durable-session policy: native *tool* results
|
||||
# are transient input for the current model call. User attachments are
|
||||
# a separate product contract and must remain intact here.
|
||||
if not isinstance(message, dict) or message.get('role') != 'tool':
|
||||
continue
|
||||
content = message.get('content')
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
|
||||
compacted_content = []
|
||||
image_parts = 0
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
# A provider can legally return scalar content alongside typed
|
||||
# parts. Preserve it unchanged rather than flattening/reordering
|
||||
# the structured result during durable-session compaction.
|
||||
compacted_content.append(part)
|
||||
continue
|
||||
part_type = part.get('type')
|
||||
# Guard the set-membership with an isinstance check: a JSON-valid
|
||||
# part can carry an unhashable ``type`` (e.g. a list), and
|
||||
# ``unhashable in {...}`` raises TypeError — which would turn an
|
||||
# otherwise-complete streaming send into the error path before the
|
||||
# session is saved. Only the three string image types are compacted;
|
||||
# every other part (including non-string ``type`` values) is preserved.
|
||||
if isinstance(part_type, str) and part_type in {'image', 'image_url', 'input_image'}:
|
||||
compacted_content.append({'type': 'text', 'text': '[screenshot]'})
|
||||
image_parts += 1
|
||||
else:
|
||||
compacted_content.append(part)
|
||||
|
||||
if image_parts:
|
||||
message['content'] = compacted_content
|
||||
changed += image_parts
|
||||
return changed
|
||||
|
||||
|
||||
def _compact_session_image_parts_for_persistence(session) -> int:
|
||||
"""Compact completed native-vision tool results in both durable histories."""
|
||||
changed = (
|
||||
_compact_image_parts_for_persistence(getattr(session, 'context_messages', None))
|
||||
+ _compact_image_parts_for_persistence(getattr(session, 'messages', None))
|
||||
)
|
||||
if changed:
|
||||
logger.info(
|
||||
"Compacted %d completed image message part(s) for session %s",
|
||||
changed,
|
||||
getattr(session, 'session_id', None),
|
||||
)
|
||||
return changed
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(
|
||||
messages,
|
||||
*,
|
||||
@@ -5335,6 +5477,7 @@ def _save_streaming_checkpoint(session):
|
||||
session,
|
||||
"streaming checkpoint",
|
||||
logger_override=logger,
|
||||
scope_skill_modules=False,
|
||||
):
|
||||
session.save(skip_index=True)
|
||||
|
||||
@@ -6359,6 +6502,22 @@ def _materialize_pending_user_turn_before_error(session) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _terminal_turn_duration(session, *, now: float | None = None) -> float | None:
|
||||
"""Freeze a valid turn timer before terminal cleanup clears its origin."""
|
||||
started_at = getattr(session, 'pending_started_at', None)
|
||||
try:
|
||||
started = float(started_at)
|
||||
ended = float(time.time() if now is None else now)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(started) or not math.isfinite(ended) or started <= 0:
|
||||
return None
|
||||
elapsed = ended - started
|
||||
if elapsed < 0:
|
||||
return None
|
||||
return round(elapsed, 3)
|
||||
|
||||
|
||||
def _build_partial_message(content_text, reasoning_text, tool_calls) -> dict | None:
|
||||
"""Build a _partial assistant message from raw streaming buffers.
|
||||
|
||||
@@ -7395,6 +7554,10 @@ def _run_agent_streaming(
|
||||
_turn_session_identity_tokens = None
|
||||
_streaming_cron_profile_home_token = None
|
||||
_turn_pending_source = 'webui'
|
||||
_streaming_hermes_home_override_ctx = (None, None, False)
|
||||
_streaming_skill_home_snapshot = None
|
||||
_restore_streaming_skill_home_modules = False
|
||||
_acquired_streaming_skill_home_patch_lock = False
|
||||
# Initialised here (before any code that may raise) so the outer `finally`
|
||||
# block can safely check `if _checkpoint_stop is not None` even when an
|
||||
# exception fires before the checkpoint thread is created (Issue #765).
|
||||
@@ -7462,8 +7625,12 @@ def _run_agent_streaming(
|
||||
from api.profiles import (
|
||||
filter_runtime_env_for_gateway_parity,
|
||||
patch_skill_home_modules,
|
||||
restore_skill_home_modules,
|
||||
snapshot_skill_home_modules,
|
||||
get_hermes_home_for_profile,
|
||||
get_profile_runtime_env,
|
||||
_skill_modules_support_profile_home,
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK,
|
||||
)
|
||||
_profile_home_path = get_hermes_home_for_profile(getattr(s, 'profile', None))
|
||||
_profile_home = str(_profile_home_path)
|
||||
@@ -7475,6 +7642,10 @@ def _run_agent_streaming(
|
||||
_profile_runtime_env = {}
|
||||
_safe_profile_runtime_env = {}
|
||||
patch_skill_home_modules = None
|
||||
snapshot_skill_home_modules = None
|
||||
restore_skill_home_modules = None
|
||||
_skill_modules_support_profile_home = None
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK = None
|
||||
|
||||
# Profile-aware provider/model enrichment: when the session belongs
|
||||
# to a profile that specifies model.provider and model.default, use
|
||||
@@ -7524,6 +7695,7 @@ def _run_agent_streaming(
|
||||
session_id,
|
||||
_profile_home,
|
||||
)
|
||||
_streaming_hermes_home_override_ctx = _set_streaming_hermes_home_override(_profile_home)
|
||||
_set_thread_env(**_thread_env)
|
||||
# process_complete agent-wakeup wiring (ours-original, Option B): bind
|
||||
# this session's HERMES_SESSION_KEY to its WebUI session_id so the
|
||||
@@ -7539,11 +7711,41 @@ def _run_agent_streaming(
|
||||
ensure_agent_runtime_current()
|
||||
_prewarm_skill_tool_modules()
|
||||
_install_streaming_cronjob_profile_wrapper()
|
||||
|
||||
# Full-turn serialization is only needed for static/legacy skill-module
|
||||
# resolution, where process-global skill-module globals are still used.
|
||||
# Dynamic-capable modules continue concurrent execution.
|
||||
_streaming_override_installed = bool(_streaming_hermes_home_override_ctx[2])
|
||||
_streaming_modules_are_dynamic = False
|
||||
if patch_skill_home_modules is not None and snapshot_skill_home_modules is not None:
|
||||
if _streaming_override_installed and _skill_modules_support_profile_home is not None:
|
||||
try:
|
||||
_streaming_modules_are_dynamic = bool(
|
||||
_skill_modules_support_profile_home(_profile_home_path)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to evaluate streaming skill-module home capability for profile %r",
|
||||
_profile_home,
|
||||
exc_info=True,
|
||||
)
|
||||
_streaming_modules_are_dynamic = False
|
||||
|
||||
if not (_streaming_override_installed and _streaming_modules_are_dynamic):
|
||||
_restore_streaming_skill_home_modules = True
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK.acquire()
|
||||
_acquired_streaming_skill_home_patch_lock = True
|
||||
|
||||
# Still set process-level env as fallback for tools that bypass thread-local
|
||||
# Acquire lock only for the env mutation, then release before the agent runs.
|
||||
# The finally block re-acquires to restore — keeping critical sections short
|
||||
# and preventing a deadlock where the restore would re-enter the same lock.
|
||||
with _ENV_LOCK:
|
||||
if _restore_streaming_skill_home_modules:
|
||||
# Snapshot and patch before mutating process env so setup
|
||||
# failures can unwind without leaking either state.
|
||||
_streaming_skill_home_snapshot = snapshot_skill_home_modules()
|
||||
patch_skill_home_modules(Path(_profile_home))
|
||||
old_profile_env = {key: os.environ.get(key) for key in _safe_profile_runtime_env}
|
||||
old_cwd = os.environ.get('TERMINAL_CWD')
|
||||
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
|
||||
@@ -7563,18 +7765,14 @@ def _run_agent_streaming(
|
||||
os.environ['HERMES_SESSION_CHAT_ID'] = str(session_id)
|
||||
if _profile_home:
|
||||
os.environ['HERMES_HOME'] = _profile_home
|
||||
# Patch skill module caches to match the active profile.
|
||||
# _set_hermes_home() does this for process-wide switches
|
||||
# but per-request switches skip it (#1700). The in-chat
|
||||
# cronjob tool is wrapped separately at its tool-call boundary
|
||||
# with cron_profile_context_for_home (#4580) so cron.jobs path
|
||||
# caches are not mutated for the entire agent turn.
|
||||
# Modules were prewarmed by _prewarm_skill_tool_modules()
|
||||
# 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))
|
||||
# Prefer context-local Hermes-home overrides when available.
|
||||
# In that mode, tools.skills_tool._skills_dir() and
|
||||
# tools.skill_manager_tool._skills_dir() can resolve the active
|
||||
# profile from get_hermes_home() and keep per-thread isolation
|
||||
# without mutating module globals. If override installation
|
||||
# succeeds for both modules, skip process-cache patching.
|
||||
# If either module is static/missing/raises, the legacy path
|
||||
# above has already snapshotted and patched under this lock.
|
||||
# Lock released — agent runs without holding it
|
||||
# ── MCP Server Discovery (lazy import, idempotent) ──
|
||||
# MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()`
|
||||
@@ -7610,14 +7808,10 @@ def _run_agent_streaming(
|
||||
try:
|
||||
from api.route_approvals import (
|
||||
submit_gateway_pending_mirror as _submit_pending_for_polling,
|
||||
reconcile_gateway_pending_mirror_locked as _reconcile_gateway_pending_mirror_locked,
|
||||
_approval_sse_notify_locked as _approval_sse_notify_locked,
|
||||
_lock as _approval_lock,
|
||||
retire_gateway_pending_mirror as _retire_gateway_pending_mirror,
|
||||
)
|
||||
def _cleanup_gateway_pending_mirror():
|
||||
with _approval_lock:
|
||||
head, total, _changed = _reconcile_gateway_pending_mirror_locked(session_id)
|
||||
_approval_sse_notify_locked(session_id, head, total)
|
||||
_retire_gateway_pending_mirror(session_id)
|
||||
except ImportError:
|
||||
_submit_pending_for_polling = None
|
||||
_cleanup_gateway_pending_mirror = None
|
||||
@@ -7628,7 +7822,8 @@ def _run_agent_streaming(
|
||||
def _approval_notify_cb(approval_data):
|
||||
if _submit_pending_for_polling is not None:
|
||||
try:
|
||||
_submit_pending_for_polling(session_id, approval_data)
|
||||
head, total = _submit_pending_for_polling(session_id, approval_data)
|
||||
approval_data = {**(head or approval_data), "pending_count": total}
|
||||
except Exception:
|
||||
logger.warning("Failed to mirror approval into WebUI polling state", exc_info=True)
|
||||
put('approval', approval_data)
|
||||
@@ -8043,20 +8238,9 @@ def _run_agent_streaming(
|
||||
if _has_blocking_approval(session_id):
|
||||
p = None
|
||||
with _approval_lock:
|
||||
_reconcile_gateway_pending_mirror_locked(session_id)
|
||||
queue = _approval_pending.get(session_id)
|
||||
if isinstance(queue, list):
|
||||
p = dict(queue[0]) if queue else None
|
||||
elif queue:
|
||||
p = dict(queue)
|
||||
if p is None:
|
||||
gw_queue = _approval_gateway_queues.get(session_id) or []
|
||||
if gw_queue:
|
||||
raw = getattr(gw_queue[0], 'data', None) or {}
|
||||
if raw:
|
||||
p = dict(raw)
|
||||
else:
|
||||
logger.warning("Gateway queue entry for %s has no .data attribute", session_id)
|
||||
p, pending_count, _changed = _reconcile_gateway_pending_mirror_locked(session_id)
|
||||
if p:
|
||||
p = {**p, "pending_count": pending_count}
|
||||
if p:
|
||||
put('approval', p)
|
||||
except ImportError:
|
||||
@@ -9045,6 +9229,7 @@ def _run_agent_streaming(
|
||||
msg_text,
|
||||
source=getattr(s, 'pending_user_source', None) or 'webui',
|
||||
)
|
||||
_compact_session_image_parts_for_persistence(s)
|
||||
_advance_truncation_watermark_after_commit(s) # #3831
|
||||
# Strip XML tool-call blocks from assistant message content.
|
||||
# DeepSeek and some other providers emit <function_calls>...</function_calls>
|
||||
@@ -9389,8 +9574,8 @@ def _run_agent_streaming(
|
||||
msg_text,
|
||||
source=getattr(s, 'pending_user_source', None) or 'webui',
|
||||
)
|
||||
_compact_session_image_parts_for_persistence(s)
|
||||
_advance_truncation_watermark_after_commit(s) # #3831
|
||||
# Skip the error block — jump directly to the
|
||||
# normal post-result persistence path by
|
||||
# leaving _assistant_added truthy (set below).
|
||||
_assistant_added = True # prevent re-entering guard
|
||||
@@ -9458,14 +9643,7 @@ def _run_agent_streaming(
|
||||
+ 'send a message, switch the model/provider, or fix the credentials.'
|
||||
)
|
||||
_error_payload['hint'] = _err_hint
|
||||
# Freeze turn duration before terminal cleanup clears pending_started_at (#6309)
|
||||
_turn_duration_seconds = 0.0
|
||||
try:
|
||||
_pending_ts = getattr(s, 'pending_started_at', None)
|
||||
if _pending_ts:
|
||||
_turn_duration_seconds = max(0.0, time.time() - float(_pending_ts))
|
||||
except Exception:
|
||||
pass
|
||||
_turn_duration = _terminal_turn_duration(s)
|
||||
_materialize_pending_user_turn_before_error(s)
|
||||
s.active_stream_id = None
|
||||
s.pending_user_message = None
|
||||
@@ -9485,8 +9663,9 @@ def _run_agent_streaming(
|
||||
'content': _error_content,
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
'_turnDuration': round(_turn_duration_seconds, 3),
|
||||
}
|
||||
if _turn_duration is not None:
|
||||
_error_message['_turnDuration'] = _turn_duration
|
||||
if _err_type == 'compression_exhausted':
|
||||
_recovery = stamp_compression_exhausted_recovery(
|
||||
s,
|
||||
@@ -9738,6 +9917,11 @@ def _run_agent_streaming(
|
||||
requested_model=resolved_model or model,
|
||||
requested_provider=resolved_provider,
|
||||
)
|
||||
# #6068: the served model must be read AFTER agent.run — the agent
|
||||
# mutates agent.model when a fallback fires, so the pre-run
|
||||
# resolved_model would mis-attribute exactly the turns where
|
||||
# attribution matters most.
|
||||
_used_model = getattr(agent, 'model', None) or resolved_model or model
|
||||
if _gateway_routing:
|
||||
s.gateway_routing = _gateway_routing
|
||||
_history = list(getattr(s, 'gateway_routing_history', None) or [])
|
||||
@@ -9754,6 +9938,8 @@ def _run_agent_streaming(
|
||||
_ttft_ms = meter().get_ttft_ms(stream_id)
|
||||
if _ttft_ms is not None:
|
||||
_dm['_firstTokenMs'] = _ttft_ms
|
||||
if _used_model:
|
||||
_dm['_usedModel'] = _used_model
|
||||
break
|
||||
# Persist context window data on the session so the context-ring
|
||||
# indicator survives a page reload (#1318). Must run BEFORE
|
||||
@@ -10141,6 +10327,8 @@ def _run_agent_streaming(
|
||||
_ttft_ms = meter().get_ttft_ms(stream_id)
|
||||
if _ttft_ms is not None:
|
||||
usage['ttft_ms'] = _ttft_ms
|
||||
if _used_model:
|
||||
usage['used_model'] = _used_model
|
||||
# Include context window data from the agent's compressor for the UI indicator.
|
||||
# The session-level persistence happens above (before s.save()) so the values
|
||||
# survive a page reload; this block only populates the live SSE usage payload.
|
||||
@@ -10609,6 +10797,7 @@ def _run_agent_streaming(
|
||||
msg_text,
|
||||
source=getattr(s, 'pending_user_source', None) or 'webui',
|
||||
)
|
||||
_compact_session_image_parts_for_persistence(s)
|
||||
_advance_truncation_watermark_after_commit(s) # #3831
|
||||
s.save()
|
||||
logger.info('[webui] self-heal (except path): retry succeeded')
|
||||
@@ -10690,14 +10879,7 @@ def _run_agent_streaming(
|
||||
+ 'send a message, switch the model/provider, or fix the credentials.'
|
||||
)
|
||||
_error_payload['hint'] = _exc_hint
|
||||
# Freeze turn duration before terminal cleanup clears pending_started_at (#6309)
|
||||
_turn_duration_seconds = 0.0
|
||||
try:
|
||||
_pending_ts = getattr(s, 'pending_started_at', None)
|
||||
if _pending_ts:
|
||||
_turn_duration_seconds = max(0.0, time.time() - float(_pending_ts))
|
||||
except Exception:
|
||||
pass
|
||||
_turn_duration = _terminal_turn_duration(s)
|
||||
_materialize_pending_user_turn_before_error(s)
|
||||
s.active_stream_id = None
|
||||
s.pending_user_message = None
|
||||
@@ -10713,8 +10895,9 @@ def _run_agent_streaming(
|
||||
'content': f'**{_exc_label}:** {_error_payload.get("message") or err_str}' + (f'\n\n*{_exc_hint}*' if _exc_hint else ''),
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
'_turnDuration': round(_turn_duration_seconds, 3),
|
||||
}
|
||||
if _turn_duration is not None:
|
||||
_error_message['_turnDuration'] = _turn_duration
|
||||
if _exc_type == 'compression_exhausted':
|
||||
_recovery = stamp_compression_exhausted_recovery(
|
||||
s,
|
||||
@@ -10784,6 +10967,19 @@ def _run_agent_streaming(
|
||||
_clear_thread_env() # TD1: always clear thread-local context
|
||||
if _streaming_cron_profile_home_token is not None:
|
||||
_STREAMING_CRON_PROFILE_HOME.reset(_streaming_cron_profile_home_token)
|
||||
if _restore_streaming_skill_home_modules and _streaming_skill_home_snapshot is not None:
|
||||
with _ENV_LOCK:
|
||||
if restore_skill_home_modules is not None:
|
||||
try:
|
||||
restore_skill_home_modules(_streaming_skill_home_snapshot)
|
||||
except Exception:
|
||||
logger.debug("Failed to restore skill module state for streaming profile", exc_info=True)
|
||||
_streaming_skill_home_snapshot = None
|
||||
_restore_streaming_skill_home_modules = False
|
||||
if _acquired_streaming_skill_home_patch_lock:
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK.release()
|
||||
_acquired_streaming_skill_home_patch_lock = False
|
||||
_reset_streaming_hermes_home_override(*_streaming_hermes_home_override_ctx)
|
||||
# xsession wakeup misroute root fix (Option 1): restore the per-turn
|
||||
# session-identity context-locals (reset-token semantics). MUST run on
|
||||
# every exit path so a reused thread-pool worker leaks no identity and
|
||||
@@ -10800,6 +10996,9 @@ def _run_agent_streaming(
|
||||
STREAM_GOAL_RELATED.pop(stream_id, None) # Clean up goal-related flag (#1932)
|
||||
STREAM_LAST_EVENT_ID.pop(stream_id, None) # Clean up event_id pointer (stage-364)
|
||||
unregister_active_run(stream_id)
|
||||
# Clean up the stream-owner registry so stale stream_id→session_id
|
||||
# mappings do not accumulate over thousands of completed streams (#6351).
|
||||
unregister_stream_owner(stream_id)
|
||||
# NOTE: do NOT discard PENDING_GOAL_CONTINUATION here. The marker
|
||||
# is set by goal_continue (line ~3328) inside the SAME function
|
||||
# call and consumed atomically by `_start_chat_stream_for_session`
|
||||
|
||||
@@ -385,11 +385,25 @@ _pid_listens_on_port() {
|
||||
local pid="$1" port="$2"
|
||||
[[ "${pid}" =~ ^[0-9]+$ && "${port}" =~ ^[0-9]+$ ]] || return 2
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
if lsof -nP -p "${pid}" -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
if lsof -nP -a -p "${pid}" -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
return 0 # PID is listening on that port → real conflict
|
||||
fi
|
||||
return 1 # PID is alive but NOT listening on that port → no conflict
|
||||
fi
|
||||
# Linux hosts (where the systemd guard runs) often lack lsof but ship ss.
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
local ss_out rows
|
||||
if ! ss_out="$(ss -tlnp 2>/dev/null)"; then
|
||||
return 2
|
||||
fi
|
||||
rows="$(printf '%s\n' "${ss_out}" | awk -v p="${port}" '$4 ~ (":" p "$")')"
|
||||
[[ -z "${rows}" ]] && return 1 # nothing listens on that port at all
|
||||
printf '%s\n' "${rows}" | grep -q "pid=${pid}," && return 0
|
||||
# Listener rows exist; when they carry pid= attribution and none is ours,
|
||||
# some OTHER process owns the port — not a conflict for this pid.
|
||||
printf '%s\n' "${rows}" | grep -q "pid=" && return 1
|
||||
return 2 # listener present but unattributable
|
||||
fi
|
||||
return 2 # can't determine
|
||||
}
|
||||
|
||||
@@ -424,6 +438,152 @@ _launchd_webui_pid() {
|
||||
esac
|
||||
}
|
||||
|
||||
_probe_target_host() {
|
||||
# A bind-all host (0.0.0.0 / ::) is not a connectable probe target; probe
|
||||
# loopback instead (mirrors server.py _abort_if_already_serving). IPv6
|
||||
# literals must be bracketed for URL interpolation ("http://[::1]:8787"),
|
||||
# or curl/wget reject the URL and an existing instance is missed.
|
||||
local host="${1:-}"
|
||||
case "${host}" in
|
||||
0.0.0.0 | '' | ::) printf '127.0.0.1' ;;
|
||||
\[*\]) printf '%s' "${host}" ;;
|
||||
*:*) printf '[%s]' "${host}" ;;
|
||||
*) printf '%s' "${host}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_port_answers_http() {
|
||||
# True when ANYTHING answers an HTTP(S) request on host:port — mirrors
|
||||
# server.py's _abort_if_already_serving, which treats any response bytes
|
||||
# (including an error status from a foreign app squatting the port) as a
|
||||
# conflict. Deliberately broader than hermes_webui_probe_health, which
|
||||
# requires a 200 from /health.
|
||||
local host="$1" port="$2" url rc scheme
|
||||
for scheme in http https; do
|
||||
url="${scheme}://${host}:${port}/health"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
# No -f: an HTTP error status is still a responder. -k: a self-signed
|
||||
# cert is still a responder. --noproxy: this is a LOCAL ownership
|
||||
# check — routed through an http(s)_proxy it would report the proxy,
|
||||
# not the port (false block or, with a dead proxy, a missed conflict).
|
||||
curl -sS -o /dev/null -k --noproxy '*' --max-time 2 "${url}" 2>/dev/null
|
||||
rc=$?
|
||||
[[ ${rc} -eq 0 ]] && return 0
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO /dev/null --no-check-certificate --no-proxy "--timeout=2" --tries=1 "${url}" 2>/dev/null
|
||||
rc=$?
|
||||
# 0 = OK; 8 = server answered with an error status — both are responders.
|
||||
[[ ${rc} -eq 0 || ${rc} -eq 8 ]] && return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_port_listener_diag() {
|
||||
# Best-effort one-liner describing what listens on TCP port $1. Used in
|
||||
# error/status messages only — never fails the caller.
|
||||
local port="$1" line=""
|
||||
# The || true keeps the assignments non-failing when errexit is inherited
|
||||
# into command substitutions (shopt inherit_errexit, or a BASHOPTS env from
|
||||
# the invoking shell) — without it a no-match awk/ss aborts status/stop.
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
line="$(ss -tlnp 2>/dev/null | awk -v p="${port}" '$4 ~ (":" p "$") {print; exit}')" || true
|
||||
fi
|
||||
if [[ -z "${line}" ]] && command -v lsof >/dev/null 2>&1; then
|
||||
line="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print; exit}')" || true
|
||||
fi
|
||||
if [[ -n "${line}" ]]; then
|
||||
printf '%s' "${line}"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
_systemd_unit_effective_port() {
|
||||
# Best-effort resolution of the port a systemd unit is configured to bind:
|
||||
# HERMES_WEBUI_PORT in its Environment=, then an explicit --port on its
|
||||
# ExecStart=. Prints nothing when the binding cannot be determined so the
|
||||
# caller can fall back to the conservative default-port guard.
|
||||
local scope="$1" unit="$2" env_block="" exec_block="" port=""
|
||||
env_block="$(systemctl "${scope}" show -p Environment --value "${unit}" 2>/dev/null)" || true
|
||||
if [[ "${env_block}" =~ HERMES_WEBUI_PORT=([0-9]+) ]]; then
|
||||
port="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
if [[ -z "${port}" ]]; then
|
||||
exec_block="$(systemctl "${scope}" show -p ExecStart --value "${unit}" 2>/dev/null)" || true
|
||||
if [[ "${exec_block}" =~ --port[=[:space:]]([0-9]+) ]]; then
|
||||
port="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
fi
|
||||
printf '%s' "${port}"
|
||||
}
|
||||
|
||||
_systemd_webui_conflict() {
|
||||
# Linux analog of _launchd_webui_pid: echo a short conflict descriptor and
|
||||
# return 0 when a systemd unit (default hermes-webui.service, override via
|
||||
# HERMES_WEBUI_SYSTEMD_UNIT) effectively owns the instance we are about to
|
||||
# start. Two conflict shapes:
|
||||
# - ActiveState=active and the unit's MainPID listens on our port.
|
||||
# - ActiveState=activating/reloading (Restart= backoff between attempts):
|
||||
# the port is briefly silent, but the unit WILL respawn and re-bind; a
|
||||
# ctl.sh-started server would trap it in a permanent crash loop
|
||||
# (each systemd attempt aborts on the "already responding" check, 5s
|
||||
# later the next one dies the same way — observed in the field).
|
||||
# Port scoping mirrors the launchd #3291 fix: when the unit's port cannot
|
||||
# be determined, only the default port is guarded, so alternate-port test
|
||||
# instances are never wrongly blocked.
|
||||
[[ "${HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT:-0}" == "1" ]] && return 1
|
||||
command -v systemctl >/dev/null 2>&1 || return 1
|
||||
local unit="${HERMES_WEBUI_SYSTEMD_UNIT:-hermes-webui.service}"
|
||||
[[ -n "${unit}" ]] || return 1
|
||||
local want_port="${CTL_PORT:-${HERMES_WEBUI_PORT:-8787}}"
|
||||
local scope state main_pid unit_port
|
||||
for scope in --system --user; do
|
||||
state="$(systemctl "${scope}" show -p ActiveState --value "${unit}" 2>/dev/null)" || continue
|
||||
case "${state}" in
|
||||
active)
|
||||
main_pid="$(systemctl "${scope}" show -p MainPID --value "${unit}" 2>/dev/null)"
|
||||
[[ "${main_pid}" =~ ^[0-9]+$ ]] || main_pid=0
|
||||
if (( main_pid > 0 )); then
|
||||
_pid_listens_on_port "${main_pid}" "${want_port}"
|
||||
case "$?" in
|
||||
0) printf 'unit %s is active (MainPID %s listens on port %s)' "${unit}" "${main_pid}" "${want_port}"; return 0 ;;
|
||||
1) continue ;; # active on a different port → no conflict
|
||||
esac
|
||||
fi
|
||||
# Port ownership unknown via PID: resolve the unit's CONFIGURED
|
||||
# binding and refuse only on actual overlap. Only when the binding
|
||||
# cannot be determined fall back to guarding the default port
|
||||
# (see #3291) so alternate-port instances are never wrongly blocked.
|
||||
unit_port="$(_systemd_unit_effective_port "${scope}" "${unit}")" || true
|
||||
if [[ -n "${unit_port}" ]]; then
|
||||
if [[ "${unit_port}" == "${want_port}" ]]; then
|
||||
printf 'unit %s is active (configured for port %s)' "${unit}" "${unit_port}"
|
||||
return 0
|
||||
fi
|
||||
elif [[ "${want_port}" == "8787" ]]; then
|
||||
printf 'unit %s is active' "${unit}"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
activating | reloading)
|
||||
unit_port="$(_systemd_unit_effective_port "${scope}" "${unit}")" || true
|
||||
if [[ -n "${unit_port}" ]]; then
|
||||
if [[ "${unit_port}" == "${want_port}" ]]; then
|
||||
printf 'unit %s is %s (auto-restart pending on port %s)' "${unit}" "${state}" "${unit_port}"
|
||||
return 0
|
||||
fi
|
||||
elif [[ "${want_port}" == "8787" ]]; then
|
||||
printf 'unit %s is %s (auto-restart pending)' "${unit}" "${state}"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
start_cmd() {
|
||||
ensure_home
|
||||
_load_repo_dotenv_preserving_env
|
||||
@@ -446,6 +606,28 @@ start_cmd() {
|
||||
echo "[ctl] Use launchctl kickstart -k gui/$(id -u)/${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}} or disable the launchd job before using ctl.sh start." >&2
|
||||
return 2
|
||||
fi
|
||||
local systemd_conflict
|
||||
if systemd_conflict="$(_systemd_webui_conflict 2>/dev/null)"; then
|
||||
echo "[ctl] Refusing to start a second Hermes WebUI: systemd ${systemd_conflict}." >&2
|
||||
echo "[ctl] Manage that instance with systemctl instead (e.g. sudo systemctl restart ${HERMES_WEBUI_SYSTEMD_UNIT:-hermes-webui.service}), or disable the unit before using ctl.sh start. Set HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT=1 to override." >&2
|
||||
return 2
|
||||
fi
|
||||
# Generic duplicate guard: whatever supervises it, if a live server already
|
||||
# answers on the target host:port, a second one is doomed — server.py aborts
|
||||
# on its "already responding" startup check a few seconds in, AFTER ctl.sh's
|
||||
# old 0.15s aliveness check had reported success and recorded a PID file
|
||||
# that goes stale on exit. Refuse up front instead.
|
||||
local probe_host
|
||||
probe_host="$(_probe_target_host "${CTL_HOST}")"
|
||||
if [[ "${HERMES_WEBUI_CTL_ALLOW_PORT_CONFLICT:-0}" != "1" ]] \
|
||||
&& _port_answers_http "${probe_host}" "${CTL_PORT}"; then
|
||||
echo "[ctl] Refusing to start: a live server is already responding on ${probe_host}:${CTL_PORT}." >&2
|
||||
local listener_diag
|
||||
listener_diag="$(_port_listener_diag "${CTL_PORT}")"
|
||||
[[ -n "${listener_diag}" ]] && echo "[ctl] listener: ${listener_diag}" >&2
|
||||
echo "[ctl] Stop that instance first (its own supervisor may restart it — check systemctl/launchctl)." >&2
|
||||
return 2
|
||||
fi
|
||||
_clear_stale_pid >/dev/null 2>&1 || true
|
||||
|
||||
local python_exe pid
|
||||
@@ -461,7 +643,33 @@ start_cmd() {
|
||||
|
||||
printf '%s\n' "${pid}" > "${PID_FILE}"
|
||||
_write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" "${python_exe}"
|
||||
sleep 0.15
|
||||
# Watch the child through its startup window instead of a single 0.15s
|
||||
# aliveness check. A server that dies during startup (bad venv, import
|
||||
# error, port stolen between guard and bind) exits after ~1-3s — after the
|
||||
# old check had already printed success and left a stale PID file behind.
|
||||
# Break early as soon as /health answers; report failure the moment the
|
||||
# process dies. HERMES_WEBUI_START_GRACE (integer seconds, default 3)
|
||||
# bounds the wait for servers that need longer before /health responds —
|
||||
# on timeout we keep the optimistic legacy behavior and say so.
|
||||
local grace="${HERMES_WEBUI_START_GRACE:-3}"
|
||||
[[ "${grace}" =~ ^[0-9]+$ ]] || grace=3
|
||||
# 0 would skip startup monitoring entirely and restore the stale-PID
|
||||
# behavior this window exists to prevent; treat it like any invalid value.
|
||||
(( grace > 0 )) || grace=3
|
||||
local grace_steps=$(( grace * 4 )) step=0 healthy=0
|
||||
while (( step < grace_steps )); do
|
||||
if ! _is_alive "${pid}"; then
|
||||
echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2
|
||||
rm -f "${PID_FILE}" "${STATE_FILE}"
|
||||
return 1
|
||||
fi
|
||||
if hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 direct >/dev/null 2>&1; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
step=$(( step + 1 ))
|
||||
done
|
||||
if ! _is_alive "${pid}"; then
|
||||
echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2
|
||||
rm -f "${PID_FILE}" "${STATE_FILE}"
|
||||
@@ -470,6 +678,28 @@ start_cmd() {
|
||||
echo "[ctl] Started Hermes WebUI (PID ${pid})"
|
||||
echo "[ctl] Bound: ${CTL_HOST}:${CTL_PORT}"
|
||||
echo "[ctl] Log: ${LOG_FILE}"
|
||||
if (( ! healthy )); then
|
||||
echo "[ctl] Note: /health did not respond within ${grace}s; check '$(basename "$0") status' shortly."
|
||||
fi
|
||||
}
|
||||
|
||||
_warn_if_unmanaged_instance_serving() {
|
||||
# After stop concluded "nothing to do", check whether a server is STILL
|
||||
# answering on the configured port. Silently reporting "stopped" while a
|
||||
# foreign (e.g. systemd-supervised) instance keeps serving is how operators
|
||||
# end up starting doomed duplicates.
|
||||
_load_state_if_present
|
||||
local host="${HOST:-${HERMES_WEBUI_HOST:-127.0.0.1}}"
|
||||
local port="${PORT:-${HERMES_WEBUI_PORT:-8787}}"
|
||||
local probe_host
|
||||
probe_host="$(_probe_target_host "${host}")"
|
||||
if _port_answers_http "${probe_host}" "${port}"; then
|
||||
echo "[ctl] Warning: an instance NOT managed by ctl.sh is still serving ${probe_host}:${port} — not touching it." >&2
|
||||
local listener_diag
|
||||
listener_diag="$(_port_listener_diag "${port}")"
|
||||
[[ -n "${listener_diag}" ]] && echo "[ctl] listener: ${listener_diag}" >&2
|
||||
echo "[ctl] If it is supervised (systemd/launchd), stop or restart it there instead." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
stop_cmd() {
|
||||
@@ -477,11 +707,15 @@ stop_cmd() {
|
||||
local pid
|
||||
if ! pid="$(_pid_from_file 2>/dev/null)"; then
|
||||
echo "[ctl] Hermes WebUI is stopped"
|
||||
# Warn BEFORE deleting the state file: it carries the saved host/port
|
||||
# binding the probe needs when the instance was started off-default.
|
||||
_warn_if_unmanaged_instance_serving
|
||||
rm -f "${PID_FILE}" "${STATE_FILE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! _is_alive "${pid}" || ! _is_owned_webui_pid "${pid}"; then
|
||||
_warn_if_unmanaged_instance_serving
|
||||
_clear_stale_pid
|
||||
return 0
|
||||
fi
|
||||
@@ -551,11 +785,29 @@ status_cmd() {
|
||||
echo " Health: ${health}"
|
||||
else
|
||||
[[ -f "${PID_FILE}" ]] && _clear_stale_pid >/dev/null 2>&1 || true
|
||||
echo "● hermes-webui — stopped"
|
||||
echo " PID: -"
|
||||
echo " Bound: ${host}:${port}"
|
||||
echo " Log: ${log_path}"
|
||||
echo " Health: not checked"
|
||||
local probe_host
|
||||
probe_host="$(_probe_target_host "${host}")"
|
||||
if _port_answers_http "${probe_host}" "${port}"; then
|
||||
# Something serves the port but ctl.sh does not own it (systemd unit,
|
||||
# launchd job, manual run). Saying "stopped" here is what leads
|
||||
# operators to start a doomed duplicate.
|
||||
health="$(_health_line "${probe_host}" "${port}")"
|
||||
echo "● hermes-webui — running (not managed by ctl.sh)"
|
||||
local listener_diag
|
||||
listener_diag="$(_port_listener_diag "${port}")"
|
||||
echo " PID: -"
|
||||
[[ -n "${listener_diag}" ]] && echo " Listener: ${listener_diag}"
|
||||
echo " Bound: ${host}:${port}"
|
||||
echo " Log: ${log_path}"
|
||||
echo " Health: ${health}"
|
||||
echo " Note: manage it via its own supervisor (systemctl/launchctl) or the process directly."
|
||||
else
|
||||
echo "● hermes-webui — stopped"
|
||||
echo " PID: -"
|
||||
echo " Bound: ${host}:${port}"
|
||||
echo " Log: ${log_path}"
|
||||
echo " Health: not checked"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
+35
-1
@@ -165,7 +165,8 @@ opt-in proxy:
|
||||
"sidecar": {
|
||||
"type": "loopback",
|
||||
"origin": "http://127.0.0.1:17787",
|
||||
"health_path": "/health"
|
||||
"health_path": "/health",
|
||||
"proxy_auth": "token-v1"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -178,6 +179,39 @@ declared and optionally check its health from the browser. If the operator later
|
||||
approves the proxy in **Settings → Extensions**, WebUI may proxy requests only
|
||||
through the fixed per-extension sidecar path for that extension.
|
||||
|
||||
### Sidecar proxy authentication (`proxy_auth`)
|
||||
|
||||
The loopback port a sidecar binds is reachable by **any local process**, and the
|
||||
proxy strips every inbound credential (cookies, `Authorization`, CSRF, `x-hermes-*`)
|
||||
before forwarding — so a sidecar cannot, on its own, tell a proxied request from a
|
||||
direct one. The `proxy_auth` field closes that gap:
|
||||
|
||||
- **`token-v1`** (recommended for any sidecar that mutates state) — WebUI mints a
|
||||
per-extension secret at `STATE_DIR/sidecar-auth/<id>.token` (mode `0600`) and
|
||||
injects it as the `X-Hermes-Sidecar-Token` header on every proxied request. The
|
||||
sidecar resolves the token file in this order — `HERMES_EXT_SIDECAR_TOKEN_FILE`
|
||||
→ `$HERMES_WEBUI_STATE_DIR/sidecar-auth/<id>.token` →
|
||||
`$HERMES_HOME/webui/sidecar-auth/<id>.token` → platform default
|
||||
(`~/.hermes/webui/…`, `%LOCALAPPDATA%\hermes\webui\…` on Windows) — and must
|
||||
validate the header on every route except `/health`, returning **`401` on a
|
||||
missing/mismatched token** and **`503` when the token file is absent/unreadable**.
|
||||
The canonical scaffold in the extensions repository (`examples/`, see
|
||||
`docs/SIDECAR_CONTRACT.md`) does all of this for you — do not hand-roll it.
|
||||
- **absent (or the explicit literal `"legacy"`)** — **legacy** mode (no token;
|
||||
unchanged behavior). Only appropriate for read-only, non-sensitive sidecars.
|
||||
- Any **other** value fails closed (the sidecar declaration is rejected).
|
||||
|
||||
**Auth-off posture:** WebUI authentication is optional and off by default. Because
|
||||
the consent endpoint and proxy route are unauthenticated in that mode, `token-v1`
|
||||
fails closed regardless of whether the sidecar origin is loopback: consent and proxy
|
||||
resolution return `403` until WebUI authentication is configured. Otherwise, any
|
||||
caller that can reach WebUI could ask core to inject the token and use it as a
|
||||
forwarding oracle. The extensions panel exposes the `local_unprotected` posture so
|
||||
the operator is told to enable authentication before granting consent. The token
|
||||
protects against other-UID and sandboxed local callers; it does **not** defend against
|
||||
arbitrary same-UID code (which can read the token file, WebUI's own signing key, or
|
||||
run the sidecar's tool directly).
|
||||
|
||||
Extension entries may declare browser-local settings when they also request
|
||||
extension-owned storage:
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@ when that slice landed; they do not override the current coverage summary above.
|
||||
`_anchor_activity_scene` snapshot so the folded activity summary can appear
|
||||
above the final answer. This handoff does not claim Transparent Stream wiring
|
||||
or durable hard-reload scene persistence.
|
||||
- The first RFC Phase 6 ownership slice routes the production `state_saved` SSE
|
||||
into the active Assistant Turn Anchor and carries `artifacts` and
|
||||
`side_effects` through `activity_scene_v1` settlement persistence and reload
|
||||
hydration. These owned outcomes remain off the readable Worklog by default;
|
||||
memory/skill saves still surface through their existing toast and natural UI.
|
||||
Production artifact-reference derivation and run-journal reconstruction of
|
||||
side effects remain follow-up work.
|
||||
|
||||
## State Layers
|
||||
|
||||
@@ -310,7 +317,7 @@ must not compete with an anchor-owned turn.
|
||||
| --- | --- | --- | --- |
|
||||
| Settled Compact Worklog activity | `_renderSettledAnchorSceneForMessage()` renders `activity_scene_v1` rows before the final answer. | Legacy `S.toolCalls`, `tool_calls`, `_partial_tool_calls`, and raw `content[].tool_use` rebuilds. | `anchorOwnedAssistantRawIdxs` excludes the anchor-owned turn from message metadata scans, fallback source collection, tool buckets, thinking buckets, and worklog-source mirroring. |
|
||||
| Settled Transparent Stream activity | `_renderSettledAnchorSceneTransparentForMessage()` renders the same scene rows as transparent event rows or inline prose before the final answer segment. | `_transparentStreamOrderedParts()` rebuilds raw `content[]` order for historical messages without an anchor scene. | `_transparentStreamOrderedParts()` returns `null` when `message._anchor_activity_scene` exists, leaving the dedicated anchor renderer in charge. |
|
||||
| Historical / non-anchor transcripts | No anchor owner is available. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover visible tool/prose history. | None. These paths remain until replay/runtime-journal coverage proves the same transcript shapes hydrate through anchors. |
|
||||
| Historical / non-anchor transcripts | Complete, user-bounded transcripts with unique top-level `assistant.tool_calls[].id` declarations, exactly one matching role=`tool` `tool_call_id` result per declaration, and a later final assistant answer hydrate an in-memory `activity_scene_v1` through the Anchor module adapter before settled rendering. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover ambiguous, incomplete, or richer historical shapes. | The ID-linked shape exits fallback ownership only after strict turn-local validation; missing or duplicate IDs/results, cross-turn matches, process prose/reasoning, and other tool schemas fail closed to the legacy renderer. |
|
||||
| Live reattach / session switch | `renderLiveAnchorActivityScene()` consumes the projected live scene, and session switch first attempts runtime-journal anchor scene restore. | Saved live DOM snapshots and `INFLIGHT` tool replay. | Snapshot fallback only runs when no usable live anchor scene can be rendered. |
|
||||
|
||||
This matrix is the current settled-render contract, backed by runtime-journal
|
||||
|
||||
+32
-14
@@ -58,31 +58,49 @@ _hermes_webui_warn_self_signed() {
|
||||
"$1" >&2
|
||||
}
|
||||
|
||||
# _hermes_webui_http_get <url> <max_time> <mode>
|
||||
# _hermes_webui_http_get <url> <max_time> <mode> [direct]
|
||||
# mode: "insecure" disables certificate verification, anything else verifies.
|
||||
# direct: "direct" bypasses every configured proxy.
|
||||
# Prints the response body to stdout; returns the underlying client exit code.
|
||||
_hermes_webui_http_get() {
|
||||
local url="$1" max_time="$2" mode="$3"
|
||||
local url="$1" max_time="$2" mode="$3" direct="${4:-}"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [[ "${mode}" == "insecure" ]]; then
|
||||
curl -fsS -k --max-time "${max_time}" "${url}" 2>/dev/null
|
||||
if [[ "${direct}" == "direct" ]]; then
|
||||
curl -fsS -k --noproxy '*' --max-time "${max_time}" "${url}" 2>/dev/null
|
||||
else
|
||||
curl -fsS -k --max-time "${max_time}" "${url}" 2>/dev/null
|
||||
fi
|
||||
else
|
||||
curl -fsS --max-time "${max_time}" "${url}" 2>/dev/null
|
||||
if [[ "${direct}" == "direct" ]]; then
|
||||
curl -fsS --noproxy '*' --max-time "${max_time}" "${url}" 2>/dev/null
|
||||
else
|
||||
curl -fsS --max-time "${max_time}" "${url}" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
return $?
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
if [[ "${mode}" == "insecure" ]]; then
|
||||
wget -qO- --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null
|
||||
if [[ "${direct}" == "direct" ]]; then
|
||||
wget -qO- --no-proxy --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null
|
||||
else
|
||||
wget -qO- --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null
|
||||
fi
|
||||
else
|
||||
wget -qO- "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null
|
||||
if [[ "${direct}" == "direct" ]]; then
|
||||
wget -qO- --no-proxy "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null
|
||||
else
|
||||
wget -qO- "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
return $?
|
||||
fi
|
||||
return 127
|
||||
}
|
||||
|
||||
# hermes_webui_probe_health <host> <port> [path] [max_time]
|
||||
# Prints the response body to stdout on success; warnings go to stderr.
|
||||
# hermes_webui_probe_health <host> <port> [path] [max_time] [direct]
|
||||
# Pass "direct" to bypass every configured proxy for all probe attempts.
|
||||
# Prints the response body to stdout; warnings go to stderr.
|
||||
# Returns 0 if the server answered, 1 otherwise.
|
||||
#
|
||||
# Side effect: sets the global _HERMES_WEBUI_PROBE_SCHEME to the scheme that
|
||||
@@ -91,14 +109,14 @@ _hermes_webui_http_get() {
|
||||
# back to plain HTTP when the cert/key are unloadable — so the configured
|
||||
# scheme can be https:// while the live server speaks http://.
|
||||
hermes_webui_probe_health() {
|
||||
local host="$1" port="$2" path="${3:-/health}" max_time="${4:-2}"
|
||||
local host="$1" port="$2" path="${3:-/health}" max_time="${4:-2}" direct="${5:-}"
|
||||
local scheme body
|
||||
scheme="$(hermes_webui_probe_scheme)"
|
||||
|
||||
local http_url="http://${host}:${port}${path}"
|
||||
|
||||
if [[ "${scheme}" == "http" ]]; then
|
||||
if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "")"; then
|
||||
if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "" "${direct}")"; then
|
||||
_HERMES_WEBUI_PROBE_SCHEME="http"
|
||||
printf '%s' "${body}"
|
||||
return 0
|
||||
@@ -111,20 +129,20 @@ hermes_webui_probe_health() {
|
||||
|
||||
if _hermes_webui_truthy "${HERMES_WEBUI_TLS_INSECURE_PROBE:-}"; then
|
||||
# Explicit opt-in: skip verification, stay silent by contract.
|
||||
if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure")"; then
|
||||
if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure" "${direct}")"; then
|
||||
_HERMES_WEBUI_PROBE_SCHEME="https"
|
||||
printf '%s' "${body}"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
# 1) Verified HTTPS.
|
||||
if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "")"; then
|
||||
if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "" "${direct}")"; then
|
||||
_HERMES_WEBUI_PROBE_SCHEME="https"
|
||||
printf '%s' "${body}"
|
||||
return 0
|
||||
fi
|
||||
# 2) Self-signed fallback: verification failed, retry unverified + warn.
|
||||
if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure")"; then
|
||||
if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure" "${direct}")"; then
|
||||
_hermes_webui_warn_self_signed "${https_url}"
|
||||
_HERMES_WEBUI_PROBE_SCHEME="https"
|
||||
printf '%s' "${body}"
|
||||
@@ -133,7 +151,7 @@ hermes_webui_probe_health() {
|
||||
fi
|
||||
|
||||
# 3) server.py may have fallen back to plain HTTP (cert/key unloadable).
|
||||
if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "")"; then
|
||||
if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "" "${direct}")"; then
|
||||
_HERMES_WEBUI_PROBE_SCHEME="http"
|
||||
printf '%s' "${body}"
|
||||
return 0
|
||||
|
||||
@@ -928,6 +928,7 @@
|
||||
);
|
||||
const done=_activityRowToolDone(kind,status,payload);
|
||||
const isError=_activityRowToolIsError(status,payload);
|
||||
const isDiff=_own(payload,'is_diff')===true||_own(payload,'isDiff')===true;
|
||||
const signatureParts=[
|
||||
toolName,
|
||||
toolCallId||'',
|
||||
@@ -943,6 +944,7 @@
|
||||
output:_sanitizePayload(_own(payload,'output'))??null,
|
||||
done,
|
||||
is_error:isError,
|
||||
...(isDiff?{is_diff:true}:{}),
|
||||
duration:_activityPayloadFirst(payload,['duration','duration_seconds','elapsed'])??null,
|
||||
started_at:_activityPayloadFirst(payload,['started_at','startedAt'])??null,
|
||||
signature:signatureParts.join('|'),
|
||||
@@ -1030,6 +1032,8 @@
|
||||
final_message_ref:null,
|
||||
terminal_state:null,
|
||||
activity_rows:Object.freeze([]),
|
||||
artifacts:Object.freeze([]),
|
||||
side_effects:Object.freeze([]),
|
||||
});
|
||||
}
|
||||
const rows=(Array.isArray(anchor.activity_events)?anchor.activity_events:[])
|
||||
@@ -1045,9 +1049,62 @@
|
||||
final_message_ref:typeof content.final_message_ref==='string'?content.final_message_ref:null,
|
||||
terminal_state:_cleanString(_own(lifecycle,'terminal_state'))||null,
|
||||
activity_rows:Object.freeze(rows),
|
||||
artifacts:Object.freeze((Array.isArray(anchor.artifacts)?anchor.artifacts:[]).map(event=>Object.freeze({
|
||||
..._copyObject(event),
|
||||
payload:Object.freeze(_copyObject(_own(event,'payload'))),
|
||||
}))),
|
||||
side_effects:Object.freeze((Array.isArray(anchor.side_effects)?anchor.side_effects:[]).map(event=>Object.freeze({
|
||||
..._copyObject(event),
|
||||
payload:Object.freeze(_copyObject(_own(event,'payload'))),
|
||||
}))),
|
||||
});
|
||||
}
|
||||
|
||||
function projectAssistantTurnAnchorHistoricalTranscriptScene(input, options){
|
||||
const item=(input&&typeof input==='object')?input:{};
|
||||
const sessionId=_cleanString(_own(item,'session_id'));
|
||||
const turnId=_cleanString(_own(item,'turn_id'));
|
||||
const localId=_cleanString(_own(item,'local_id'));
|
||||
const activityEvents=Array.isArray(_own(item,'activity_events'))?_own(item,'activity_events'):[];
|
||||
const settledMessage=_own(item,'settled_message');
|
||||
if(!sessionId||!turnId||!localId||!activityEvents.length
|
||||
||!settledMessage||typeof settledMessage!=='object') return null;
|
||||
const runId=_cleanString(_own(item,'run_id'))||null;
|
||||
const streamId=_cleanString(_own(item,'stream_id'))||null;
|
||||
const sourceMessageRefs=Array.isArray(_own(item,'source_message_refs'))
|
||||
?_own(item,'source_message_refs').map(_cleanString).filter(Boolean)
|
||||
:[];
|
||||
const registry=createAssistantTurnAnchorRegistry({
|
||||
session_id:sessionId,
|
||||
turn_id:turnId,
|
||||
run_id:runId,
|
||||
stream_id:streamId,
|
||||
local_id:localId,
|
||||
source_message_refs:sourceMessageRefs,
|
||||
});
|
||||
const context={
|
||||
session_id:sessionId,
|
||||
turn_id:turnId,
|
||||
run_id:runId,
|
||||
stream_id:streamId,
|
||||
};
|
||||
for(const event of activityEvents){
|
||||
const result=applyAssistantTurnAnchorSourceEvent(registry,event,context);
|
||||
if(!result.applied||result.normalized.classification!=='activity') return null;
|
||||
}
|
||||
const settledPayload={...settledMessage,role:_cleanString(_own(settledMessage,'role'))||'assistant'};
|
||||
const settled=applyAssistantTurnAnchorSourceEvent(registry,{
|
||||
source_type:'settled_message',
|
||||
seq:activityEvents.length+1,
|
||||
local_id:localId,
|
||||
payload:settledPayload,
|
||||
},context);
|
||||
if(!settled.applied) return null;
|
||||
const opts=(options&&typeof options==='object')?options:{};
|
||||
const requestedMode=_cleanString(_own(opts,'mode'))||_cleanString(_own(item,'mode'));
|
||||
return projectAssistantTurnAnchorActivityScene(registry,{mode:requestedMode});
|
||||
}
|
||||
|
||||
const ACTIVITY_RECONCILIATION_DEFAULT_FIELDS=Object.freeze([
|
||||
'kind',
|
||||
'role',
|
||||
@@ -1648,6 +1705,7 @@
|
||||
createAssistantTurnAnchorShadowSnapshot,
|
||||
projectAssistantTurnAnchorSettledMessageFinalAnswer,
|
||||
projectAssistantTurnAnchorActivityScene,
|
||||
projectAssistantTurnAnchorHistoricalTranscriptScene,
|
||||
reconcileAssistantTurnAnchorActivityScene,
|
||||
createAssistantTurnAnchorRendererSnapshot,
|
||||
reconcileAssistantTurnAnchorRendererSnapshot,
|
||||
|
||||
+24
-6
@@ -17,7 +17,7 @@
|
||||
async function cancelStream(reason){
|
||||
const sid = S.session && S.session.session_id;
|
||||
const streamId = S.activeStreamId;
|
||||
if(!streamId) return;
|
||||
if(!streamId) return false;
|
||||
// Interrupt provenance: log WHY the active run is being cancelled so operators
|
||||
// can tell an explicit Stop / interrupt from any other trigger when they see a
|
||||
// SIGINT/exit-code-130 in the backend logs. Only explicit user paths reach
|
||||
@@ -30,8 +30,10 @@ async function cancelStream(reason){
|
||||
console.info('[stream] cancel requested', {reason:_reason, streamId, sessionId:sid});
|
||||
}
|
||||
let respBody=null;
|
||||
let respOk=false;
|
||||
try{
|
||||
const r=await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
|
||||
respOk=!!(r&&r.ok);
|
||||
try{respBody=await r.json();}catch(_){}
|
||||
}catch(e){
|
||||
if(typeof console !== 'undefined' && console.warn){
|
||||
@@ -50,7 +52,7 @@ async function cancelStream(reason){
|
||||
// `cancel` event can clear INFLIGHT, render "Task cancelled", and refresh
|
||||
// the sidebar. Only clear locally when the backend says there is no active
|
||||
// stream left to settle.
|
||||
if(respBody && respBody.cancelled===false && S.activeStreamId===streamId){
|
||||
if(respOk && respBody && respBody.cancelled===false && S.activeStreamId===streamId){
|
||||
S.activeStreamId=null;
|
||||
setBusy(false);
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
@@ -59,20 +61,24 @@ async function cancelStream(reason){
|
||||
// distinguish reasons — keep the toast generic and short.
|
||||
if(typeof showToast==='function') showToast('Stream is no longer active',2000);
|
||||
}
|
||||
return respOk;
|
||||
}
|
||||
|
||||
async function cancelSessionStream(session){
|
||||
const streamId = session&&session.active_stream_id;
|
||||
const sid = session&&session.session_id;
|
||||
if(!streamId||!sid) return;
|
||||
if(!streamId||!sid) return false;
|
||||
// Explicit sidebar "Stop response" — log provenance for the same reason as
|
||||
// cancelStream(). (#5345)
|
||||
if(typeof console !== 'undefined' && console.info){
|
||||
console.info('[stream] cancel requested', {reason:'sidebar-stop', streamId, sessionId:sid});
|
||||
}
|
||||
let respOk=false;
|
||||
try{
|
||||
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
|
||||
const r=await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
|
||||
respOk=!!(r&&r.ok);
|
||||
}catch(e){/* close local stream; keep UI state honest below */}
|
||||
if(!respOk) return false;
|
||||
if(typeof closeLiveStream==='function') closeLiveStream(sid, streamId);
|
||||
session.active_stream_id=null;
|
||||
delete INFLIGHT[sid];
|
||||
@@ -94,6 +100,7 @@ async function cancelSessionStream(session){
|
||||
hideClarifyCard(true, 'cancelled');
|
||||
}
|
||||
if(typeof renderSessionList==='function') renderSessionList();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _savedSessionShouldStaySidebarOnly(sid){
|
||||
@@ -2538,6 +2545,11 @@ function applyEmptyStateSuggestionPref(){
|
||||
$('emptyState').classList.toggle('no-suggestions',window._hideEmptyStateSuggestions===true);
|
||||
}
|
||||
|
||||
function applyEmptyStatePanelPref(){
|
||||
if(!$('emptyState')) return;
|
||||
$('emptyState').classList.toggle('no-welcome',window._hideEmptyStatePanel===true);
|
||||
}
|
||||
|
||||
window.addEventListener('resize',()=>{
|
||||
_syncWorkspacePanelInlineWidth();
|
||||
syncWorkspacePanelState();
|
||||
@@ -3224,9 +3236,13 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
|
||||
window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true;
|
||||
applyEmptyStateSuggestionPref();
|
||||
window._hideEmptyStatePanel=s.hide_empty_state_panel===true;
|
||||
applyEmptyStatePanelPref();
|
||||
// #4343: transcript virtualization is EXPERIMENTAL/opt-IN (default OFF).
|
||||
// It caused scroll-up flicker on long sessions, so it's off for everyone
|
||||
// unless explicitly opted in; long transcripts render in full by default.
|
||||
// #4346 Phase B (footer-jitter suppression during virtual-scroll
|
||||
// measurement re-renders) resolved the scroll-up flicker root cause,
|
||||
// but virtualization remains opt-in until battle-tested further.
|
||||
// Users can explicitly enable it via Settings → virtualize_transcript.
|
||||
window._virtualizeTranscript=s.virtualize_transcript===true;
|
||||
window._showTps=!!s.show_tps;
|
||||
window._fadeTextEffect=!!s.fade_text_effect;
|
||||
@@ -3379,6 +3395,8 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
|
||||
window._hideEmptyStateSuggestions=false;
|
||||
applyEmptyStateSuggestionPref();
|
||||
window._hideEmptyStatePanel=false;
|
||||
applyEmptyStatePanelPref();
|
||||
window._virtualizeTranscript=false; // settings-load failed: default-OFF (experimental/opt-in) (#4343)
|
||||
window._showTps=false;
|
||||
window._fadeTextEffect=false;
|
||||
|
||||
+8
-3
@@ -1213,7 +1213,10 @@ async function cmdPersonality(args){
|
||||
async function cmdStop(){
|
||||
if(!S.session){showToast(t('no_active_session'));return;}
|
||||
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
|
||||
if(typeof cancelStream==='function'){await cancelStream('slash-stop');showToast(t('stream_stopped'));}
|
||||
if(typeof cancelStream==='function'){
|
||||
if(await cancelStream('slash-stop')) showToast(t('stream_stopped'));
|
||||
else showToast(t('cancel_failed'),null,'error');
|
||||
}
|
||||
else showToast(t('cancel_unavailable'));
|
||||
}
|
||||
|
||||
@@ -1319,8 +1322,10 @@ async function cmdInterrupt(args){
|
||||
updateQueueBadge(S.session.session_id);
|
||||
S.pendingFiles=[];renderTray();
|
||||
// Cancel the active stream; setBusy(false) will drain the queue
|
||||
if(typeof cancelStream==='function'){await cancelStream('slash-interrupt');}
|
||||
showToast(t('cmd_interrupt_confirm'),2000);
|
||||
if(typeof cancelStream==='function'){
|
||||
if(await cancelStream('slash-interrupt')) showToast(t('cmd_interrupt_confirm'),2000);
|
||||
else showToast(t('cancel_failed'),null,'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+241
-195
@@ -17,7 +17,7 @@ const LOCALES = {
|
||||
_speech: 'en-US',
|
||||
// boot.js
|
||||
cancelling: 'Cancelling\u2026',
|
||||
cancel_failed: 'Cancel failed: ',
|
||||
cancel_failed: 'Cancel failed.',
|
||||
mic_denied: 'Microphone access denied. Check browser permissions.',
|
||||
mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.',
|
||||
mic_no_speech: 'No speech detected. Try again.',
|
||||
@@ -165,6 +165,7 @@ const LOCALES = {
|
||||
approval_responding: 'Responding\u2026',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: 'Approvals not supported',
|
||||
approval_pending_count: (n) => `1 of ${n} pending`,
|
||||
clarify_heading: 'Clarification needed',
|
||||
clarify_hint: 'Pick a choice, or type your own answer below.',
|
||||
clarify_other: 'Other',
|
||||
@@ -464,6 +465,7 @@ const LOCALES = {
|
||||
workspace_close_preview: 'Close preview',
|
||||
workspace_files_tab: 'Files',
|
||||
workspace_artifacts_tab: 'Artifacts',
|
||||
workspace_artifact_source_session: 'session',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
workspace_options: 'Workspace options',
|
||||
@@ -874,6 +876,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Show a desktop-only jump list for user questions in the current conversation. Off by default.',
|
||||
settings_label_hide_suggestions: 'Hide new-chat suggestions',
|
||||
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
|
||||
settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel',
|
||||
settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts (experimental)',
|
||||
settings_desc_virtualize_transcript: "Experimental. Renders only the visible portion of very long chat transcripts (over 80 messages) to speed up scrolling. Off by default because it can cause scroll flicker on long sessions with large tool-call blocks. Turn it on to try it; leave it off for the full transcript to render at once (and so the browser's Find (Ctrl+F) matches the whole conversation).",
|
||||
settings_label_token_usage: 'Show token usage',
|
||||
@@ -1774,7 +1778,7 @@ const LOCALES = {
|
||||
_speech: 'it-IT',
|
||||
// boot.js
|
||||
cancelling: 'Annullamento\u2026',
|
||||
cancel_failed: 'Annullamento fallito: ',
|
||||
cancel_failed: 'Annullamento fallito.',
|
||||
mic_denied: 'Accesso al microfono negato. Controlla i permessi del browser.',
|
||||
mic_insecure_origin: 'L\'input vocale richiede una connessione sicura. Apri Hermes tramite HTTPS o da localhost per usare il microfono.',
|
||||
mic_no_speech: 'Nessuna voce rilevata. Riprova.',
|
||||
@@ -1922,6 +1926,7 @@ const LOCALES = {
|
||||
approval_responding: 'Rispondendo\u2026',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: 'Approvazioni non supportate',
|
||||
approval_pending_count: (n) => `1 di ${n} in sospeso`,
|
||||
clarify_heading: 'Chiarimento necessario',
|
||||
clarify_hint: "Scegli un'opzione o scrivi la tua risposta qui sotto.",
|
||||
clarify_other: 'Altro',
|
||||
@@ -2631,6 +2636,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Mostra un elenco di salto solo desktop per le domande utente nella conversazione corrente. Disattivato per impostazione predefinita.',
|
||||
settings_label_hide_suggestions: 'Hide new-chat suggestions',
|
||||
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
|
||||
settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel',
|
||||
settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_token_usage: 'Mostra uso token',
|
||||
@@ -3514,7 +3521,7 @@ const LOCALES = {
|
||||
_speech: 'ja-JP',
|
||||
// boot.js
|
||||
cancelling: 'キャンセル中…',
|
||||
cancel_failed: 'キャンセル失敗: ',
|
||||
cancel_failed: 'キャンセルに失敗しました。',
|
||||
mic_denied: 'マイクへのアクセスが拒否されました。ブラウザの権限を確認してください。',
|
||||
mic_insecure_origin: '音声入力にはセキュアな接続が必要です。マイクを使うには HTTPS または localhost 経由で Hermes を開いてください。',
|
||||
mic_no_speech: '音声が検出されませんでした。もう一度お試しください。',
|
||||
@@ -3632,7 +3639,7 @@ const LOCALES = {
|
||||
pdf_no_pages: 'PDF にページがありません',
|
||||
pdf_error: 'PDF プレビューの描画に失敗しました',
|
||||
pdf_download: 'PDF をダウンロード',
|
||||
pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document',
|
||||
pdf_truncated: '最初の {0} / {1} ページを表示しています。ドキュメント全体を閲覧するにはダウンロードしてください',
|
||||
// HTML sandbox preview (#482)
|
||||
html_loading: 'HTML プレビューを読み込み中…',
|
||||
html_too_large: 'HTML が大きすぎてインラインプレビューできません',
|
||||
@@ -3642,8 +3649,8 @@ const LOCALES = {
|
||||
thinking: '考え中',
|
||||
expand_all: 'すべて展開',
|
||||
collapse_all: 'すべて折りたたむ',
|
||||
show_earlier_step_one: 'Show 1 earlier step',
|
||||
show_earlier_steps: 'Show {0} earlier steps',
|
||||
show_earlier_step_one: '前のステップを1件表示',
|
||||
show_earlier_steps: '前のステップを{0}件表示',
|
||||
edit_failed: '編集失敗: ',
|
||||
regen_failed: '再生成失敗: ',
|
||||
reconnect_active: '応答を生成中です。準備ができたら再読み込みしますか?',
|
||||
@@ -3662,6 +3669,7 @@ const LOCALES = {
|
||||
approval_responding: '応答中…',
|
||||
approval_gateway_unsupported: '承認を実行するには新しいゲートウェイが必要です。有効にするには接続されている Hermes ゲートウェイをアップグレードしてください。',
|
||||
approval_gateway_unsupported_label: '承認はサポートされていません',
|
||||
approval_pending_count: (n) => `${n}件中1件保留中`,
|
||||
clarify_heading: '確認が必要',
|
||||
clarify_hint: '選択肢を選ぶか、下に独自の回答を入力してください。',
|
||||
clarify_other: 'その他',
|
||||
@@ -3693,19 +3701,19 @@ const LOCALES = {
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" は設定されたプロバイダ (${p}) で動作しない可能性があります。このまま送信するか、ターミナルで \`hermes model\` を実行して切り替えてください。`,
|
||||
provider_mismatch_label: 'プロバイダ不一致',
|
||||
gateway_auth_label: 'ゲートウェイ認証に失敗しました',
|
||||
gateway_start: 'Start',
|
||||
gateway_stop: 'Stop',
|
||||
gateway_restart: 'Restart',
|
||||
gateway_start_success: 'Gateway start completed.',
|
||||
gateway_stop_success: 'Gateway stop completed.',
|
||||
gateway_restart_success: 'Gateway restart completed.',
|
||||
gateway_start_failed: 'Gateway start failed',
|
||||
gateway_stop_failed: 'Gateway stop failed',
|
||||
gateway_restart_failed: 'Gateway restart failed',
|
||||
gateway_running: 'Running',
|
||||
gateway_session: 'session',
|
||||
gateway_sessions: 'sessions',
|
||||
gateway_status_load_failed: 'Failed to load gateway status',
|
||||
gateway_start: '起動',
|
||||
gateway_stop: '停止',
|
||||
gateway_restart: '再起動',
|
||||
gateway_start_success: 'ゲートウェイの起動が完了しました。',
|
||||
gateway_stop_success: 'ゲートウェイの停止が完了しました。',
|
||||
gateway_restart_success: 'ゲートウェイの再起動が完了しました。',
|
||||
gateway_start_failed: 'ゲートウェイの起動に失敗しました',
|
||||
gateway_stop_failed: 'ゲートウェイの停止に失敗しました',
|
||||
gateway_restart_failed: 'ゲートウェイの再起動に失敗しました',
|
||||
gateway_running: '実行中',
|
||||
gateway_session: 'セッション',
|
||||
gateway_sessions: 'セッション',
|
||||
gateway_status_load_failed: 'ゲートウェイのステータス取得に失敗しました',
|
||||
model_not_found_label: 'モデルが見つかりません',
|
||||
model_custom_label: 'カスタムモデルID',
|
||||
model_custom_placeholder: '例: openai/gpt-5.4',
|
||||
@@ -3802,16 +3810,16 @@ const LOCALES = {
|
||||
cmd_steer_delivered: 'ステア送信完了 — 次のツール結果でエージェントが認識します',
|
||||
steer_leftover_queued: 'ステアを次のターンへキュー',
|
||||
busy_steer_fallback: 'ステア利用不可 — 下書きを復元しました',
|
||||
steer_fail_no_cached_agent: 'No cached agent is available for steering this run',
|
||||
steer_fail_agent_lacks_steer: 'This agent version does not support steer',
|
||||
steer_fail_session_not_found: 'Session not found — it may have expired',
|
||||
steer_fail_not_running: 'Agent is not currently running',
|
||||
steer_fail_stream_dead: 'The agent stream has ended',
|
||||
steer_fail_steer_error: 'Steer delivery failed — the agent may have finished',
|
||||
steer_fail_network_error: 'Network error — check your connection',
|
||||
steer_fail_unknown: 'Steer unavailable',
|
||||
steer_recovery_retry: 'Retry',
|
||||
steer_recovery_dismiss: 'Dismiss',
|
||||
steer_fail_no_cached_agent: 'この実行を操作割り込み(Steer)するためのキャッシュされたエージェントがありません',
|
||||
steer_fail_agent_lacks_steer: 'このエージェントのバージョンは操作割り込み(Steer)をサポートしていません',
|
||||
steer_fail_session_not_found: 'セッションが見つかりません。期限が切れた可能性があります',
|
||||
steer_fail_not_running: 'エージェントは現在実行されていません',
|
||||
steer_fail_stream_dead: 'エージェントのストリームが終了しました',
|
||||
steer_fail_steer_error: '操作割り込み(Steer)の送信に失敗しました。エージェントが既に処理を完了している可能性があります',
|
||||
steer_fail_network_error: 'ネットワークエラーが発生しました。接続を確認してください',
|
||||
steer_fail_unknown: '操作割り込み(Steer)を利用できません',
|
||||
steer_recovery_retry: '再試行',
|
||||
steer_recovery_dismiss: '閉じる',
|
||||
busy_interrupt_confirm: '中断 — 新しいメッセージを送信中',
|
||||
settings_label_default_message_mode: '既定のメッセージモード',
|
||||
settings_desc_default_message_mode: 'エージェント実行中にメッセージを送信した時の動作を制御します。Queue は待機、Interrupt はキャンセルして再開、Steer は中断せずにターン中に修正を注入します。Steer が利用できない場合は、下書きが復元されるため、次の操作を選べます。',
|
||||
@@ -3824,14 +3832,14 @@ const LOCALES = {
|
||||
settings_default_message_mode_queue: 'フォローアップをキュー',
|
||||
settings_default_message_mode_interrupt: '現在のターンを中断',
|
||||
settings_default_message_mode_steer: 'ステア (ターン中の修正)',
|
||||
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
|
||||
settings_label_new_chat_on_workspace_switch: 'Start a new chat when switching workspace',
|
||||
settings_desc_new_chat_on_workspace_switch: 'When on, selecting a different workspace starts a fresh conversation bound to that workspace and leaves your current chat on its original workspace, avoiding stale cross-workspace context. When off (default), switching workspace changes the current workspace in place.',
|
||||
workspace_switched_new_chat: 'Started a new chat in {0}',
|
||||
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
|
||||
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
|
||||
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
|
||||
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
|
||||
settings_label_busy_placeholder_hint: '処理中のプレースホルダーヒントを表示する',
|
||||
settings_label_new_chat_on_workspace_switch: 'ワークスペース切り替え時に新しいチャットを開始',
|
||||
settings_desc_new_chat_on_workspace_switch: '有効にすると、別のワークスペースを選択したときにそのワークスペースに紐づく新しい会話を開始し、現在のチャットは元のワークスペースに残ります。ワークスペースをまたいだ古いコンテキストが混入するのを防げます。無効の場合(既定)、ワークスペースを切り替えると現在のワークスペースがその場で変更されます。',
|
||||
workspace_switched_new_chat: '{0} で新しいチャットを開始しました',
|
||||
settings_desc_busy_placeholder_hint: 'セッションが実行中で入力欄が空のときに、現在の実行中タスクをプレースホルダーとして表示します。',
|
||||
composer_placeholder_busy_queue: 'Enter = キュー登録 | /interrupt | /background | /steer',
|
||||
composer_placeholder_busy_interrupt: 'Enter = 割り込み | /queue | /background | /steer',
|
||||
composer_placeholder_busy_steer: 'Enter = 割り込み操作 | /queue | /background | /interrupt',
|
||||
|
||||
slash_skill_badge:'スキル',
|
||||
slash_skill_desc:'このスキルを呼び出す',
|
||||
@@ -3951,16 +3959,16 @@ const LOCALES = {
|
||||
workspace_empty_dir: 'このワークスペースは空です。',
|
||||
workspace_show_hidden_files: '隠しファイルを表示',
|
||||
workspace_show_hidden_files_desc: '.DS_Store、.git、node_modules などの隠しファイル・システムファイルをファイルツリーに表示します。',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
workspace_panel_close: 'Close workspace panel',
|
||||
workspace_panel_toggle: 'Toggle workspace files panel',
|
||||
workspace_panel_views: 'Workspace panel views',
|
||||
workspace_parent_directory: 'Parent directory',
|
||||
workspace_upload_file: 'Upload file',
|
||||
workspace_close_preview: 'Close preview',
|
||||
workspace_files_tab: 'Files',
|
||||
workspace_artifacts_tab: 'Artifacts',
|
||||
workspace_panel_show: 'ワークスペースパネルを表示',
|
||||
workspace_panel_hide: 'ワークスペースパネルを非表示',
|
||||
workspace_panel_close: 'ワークスペースパネルを閉じる',
|
||||
workspace_panel_toggle: 'ワークスペースファイルパネルの切り替え',
|
||||
workspace_panel_views: 'ワークスペースパネルの表示',
|
||||
workspace_parent_directory: '親ディレクトリ',
|
||||
workspace_upload_file: 'ファイルをアップロード',
|
||||
workspace_close_preview: 'プレビューを閉じる',
|
||||
workspace_files_tab: 'ファイル',
|
||||
workspace_artifacts_tab: 'アーティファクト',
|
||||
workspace_hidden_files_visible: '隠しファイル表示中',
|
||||
workspace_hidden_files_visible_title: '隠しファイルが表示されています — クリックでオプション',
|
||||
workspace_options: 'ワークスペースオプション',
|
||||
@@ -3983,10 +3991,10 @@ const LOCALES = {
|
||||
file_open_failed: 'ファイルを開けませんでした',
|
||||
downloading: (name) => `${name} をダウンロード中…`,
|
||||
double_click_rename: 'ダブルクリックで名前変更',
|
||||
symlink_link_to: 'Symlink → {target}',
|
||||
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
|
||||
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
|
||||
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
|
||||
symlink_link_to: 'シンボリックリンク → {target}',
|
||||
external_link_open_confirm: 'このシンボリックリンクはワークスペースの外部を指しています。このブラウザセッション中、読み取り専用の外部ビューで開きますか?',
|
||||
external_link_read_only: 'この外部ビューは読み取り専用です。作成、名前変更、移動、削除、アップロード、フォルダ表示、エディタ操作はここでは無効です。',
|
||||
external_link_grant_expired: 'この外部ビューの期限が切れました。再度承認するには、リンク元のシンボリックリンクを開き直してください。',
|
||||
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
|
||||
renamed_to: '名前を変更: ',
|
||||
rename_failed: '名前変更失敗: ',
|
||||
@@ -4019,20 +4027,20 @@ const LOCALES = {
|
||||
session_copy_link_desc: 'この会話への直接リンクをコピー',
|
||||
session_link_copied: '会話リンクをクリップボードにコピーしました',
|
||||
session_link_copy_failed: '会話リンクをコピーできませんでした: ',
|
||||
share_session: 'Share',
|
||||
share_session_tooltip: 'Create a public read-only share link',
|
||||
share_session_status_active: 'Public share active',
|
||||
share_session_existing_confirm: 'This conversation already has a public read-only share link.\n\nChoose Copy existing to reuse that link, or Refresh snapshot to rebuild the public snapshot from the latest visible messages.',
|
||||
share_session_copy_existing: 'Copy existing',
|
||||
share_session_refresh_snapshot: 'Refresh snapshot',
|
||||
share_session_link_copied: 'Share link copied to clipboard',
|
||||
share_session_created: 'Public share link created',
|
||||
share_session_failed: 'Share failed: ',
|
||||
stop_sharing_session: 'Stop sharing',
|
||||
stop_sharing_session_tooltip: 'Revoke the public read-only share link for this conversation',
|
||||
stop_sharing_session_confirm: 'Revoke this public share link? Anyone opening the old link will see that it is no longer available.',
|
||||
share_session_revoked: 'Public share link revoked',
|
||||
share_session_revoke_failed: 'Failed to revoke share link: ',
|
||||
share_session: '共有',
|
||||
share_session_tooltip: '公開の読み取り専用共有リンクを作成',
|
||||
share_session_status_active: '公開共有が有効です',
|
||||
share_session_existing_confirm: 'この会話にはすでに公開の読み取り専用共有リンクがあります。\n\n既存のリンクを再利用するには「既存のリンクをコピー」を、最新の表示メッセージから公開スナップショットを再構築するには「スナップショットを更新」を選択してください。',
|
||||
share_session_copy_existing: '既存のリンクをコピー',
|
||||
share_session_refresh_snapshot: 'スナップショットを更新',
|
||||
share_session_link_copied: '共有リンクをクリップボードにコピーしました',
|
||||
share_session_created: '公開共有リンクを作成しました',
|
||||
share_session_failed: '共有に失敗しました: ',
|
||||
stop_sharing_session: '共有を停止',
|
||||
stop_sharing_session_tooltip: 'この会話の公開読み取り専用共有リンクを無効にします',
|
||||
stop_sharing_session_confirm: 'この公開共有リンクを無効にしますか? 以前のリンクを開いた人には、リンクが利用できなくなったことが表示されます。',
|
||||
share_session_revoked: '公開共有リンクを無効にしました',
|
||||
share_session_revoke_failed: '共有リンクの無効化に失敗しました: ',
|
||||
new_file_prompt: '新しいファイル名 (例: notes.md):',
|
||||
new_file: '新規ファイル',
|
||||
new_folder: '新規フォルダ',
|
||||
@@ -4079,8 +4087,8 @@ const LOCALES = {
|
||||
session_archive_failed: 'アーカイブ失敗: ',
|
||||
session_duplicate: '会話を複製',
|
||||
session_duplicate_desc: '同じワークスペースとモデルでコピーを作成',
|
||||
session_export_html: 'Export as HTML',
|
||||
session_export_html_desc: 'Download this conversation as a self-contained HTML file',
|
||||
session_export_html: 'HTMLとしてエクスポート',
|
||||
session_export_html_desc: 'この会話を自己完結型のHTMLファイルとしてダウンロードします',
|
||||
session_duplicated: 'セッションを複製しました',
|
||||
session_duplicate_failed: '複製失敗: ',
|
||||
session_stop_response: '応答を停止',
|
||||
@@ -4132,15 +4140,15 @@ const LOCALES = {
|
||||
settings_check_now: '今すぐ確認',
|
||||
settings_checking: '確認中…',
|
||||
settings_up_to_date: '最新です ✓',
|
||||
update_later: 'Later',
|
||||
update_now: 'Update Now',
|
||||
update_updating: 'Updating…',
|
||||
update_force: 'Force update',
|
||||
update_clear_lock_retry: 'Clear lock and retry update',
|
||||
update_hard_refresh_now: 'Hard refresh now',
|
||||
update_no_target: 'No update target selected. Refresh update status and retry.',
|
||||
update_failed_network: 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.',
|
||||
update_failed_prefix: 'Update failed: ',
|
||||
update_later: '後で',
|
||||
update_now: '今すぐ更新',
|
||||
update_updating: '更新中…',
|
||||
update_force: '強制更新',
|
||||
update_clear_lock_retry: 'ロックを解除して更新を再試行',
|
||||
update_hard_refresh_now: '今すぐハードリフレッシュ',
|
||||
update_no_target: '更新対象が選択されていません。更新ステータスを再取得してから再試行してください。',
|
||||
update_failed_network: '更新に失敗しました: WebUIサーバーに到達できませんでした。サーバーが再起動したか、接続が中断された可能性があります。数秒待ってからページを再読み込みし、それでも復旧しない場合はサーバーを確認してください。',
|
||||
update_failed_prefix: '更新に失敗しました: ',
|
||||
settings_updates_available: '{count} 件のアップデートあり',
|
||||
settings_update_check_failed: 'アップデート確認失敗',
|
||||
settings_update_no_git: 'アップデートを確認できません(gitチェックアウトではありません)',
|
||||
@@ -4160,59 +4168,59 @@ const LOCALES = {
|
||||
settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。',
|
||||
settings_label_auto_scroll_follow: '新しい内容を自動で追従',
|
||||
settings_desc_auto_scroll_follow: '有効にすると、ストリーミング中に画面が自動で一番下までスクロールします。無効にすると、スクロール位置を自分で操作できます。',
|
||||
settings_label_render_user_markdown: 'Render markdown in user messages',
|
||||
settings_label_render_user_markdown: 'ユーザーメッセージでMarkdownをレンダリングする',
|
||||
settings_label_show_titlebar_profile: 'タイトルバーにプロファイル切替を表示',
|
||||
settings_desc_show_titlebar_profile: '有効にすると、左上のアプリのタイトルバーにプロファイル切替ボタンが表示され、どのタブからでもプロファイルを変更できます。既定ではオフです。この設定に関係なく、コンポーザーのフッターには常にプロファイル切替があります。',
|
||||
settings_desc_render_user_markdown: 'When enabled, bold, italic, links, and other markdown in your own messages are rendered. Off by default; fenced code blocks and math always render regardless of this setting.',
|
||||
settings_label_large_text_paste_as_attachment: 'Attach large pasted text as file',
|
||||
settings_desc_large_text_paste_as_attachment: 'When enabled, long pasted text becomes a .md attachment instead of filling the composer.',
|
||||
settings_label_project_quick_create: 'Show per-project new-conversation buttons',
|
||||
settings_desc_project_quick_create: 'When enabled, each project chip in the sidebar gets a + button to start a conversation in that project.',
|
||||
settings_label_structured_code: 'JSON/YAML code blocks',
|
||||
settings_option_structured_code_auto: 'Auto: Tree for long blocks',
|
||||
settings_option_structured_code_on: 'Tree by default',
|
||||
settings_option_structured_code_off: 'Raw by default',
|
||||
settings_label_structured_code_auto_lines: 'Auto threshold lines',
|
||||
settings_desc_structured_code: 'When Auto is selected, JSON/YAML blocks with at least this many lines open in Tree view. Tree always opens the structured viewer; Raw keeps the syntax-highlighted text. You can still switch Tree/Raw on each block.',
|
||||
settings_desc_render_user_markdown: '有効にすると、自分のメッセージ内の太字、斜体、リンクなどのMarkdownをレンダリングします。デフォルトはオフです。コードブロックや数式はこの設定に関わらず常にレンダリングされます。',
|
||||
settings_label_large_text_paste_as_attachment: '長いペーストテキストをファイルとして添付する',
|
||||
settings_desc_large_text_paste_as_attachment: '有効にすると、入力欄に長いテキストをペーストした際に入力欄を埋める代わりに .md ファイルとして自動添付します。',
|
||||
settings_label_project_quick_create: 'プロジェクトごとの新規会話ボタンを表示する',
|
||||
settings_desc_project_quick_create: '有効にすると、サイドバー内の各プロジェクトチップに「+」ボタンが表示され、そのプロジェクトで素早く会話を開始できるようになります。',
|
||||
settings_label_structured_code: 'JSON/YAML コードブロック',
|
||||
settings_option_structured_code_auto: '自動: 長いブロックはツリー表示',
|
||||
settings_option_structured_code_on: 'デフォルトでツリー表示',
|
||||
settings_option_structured_code_off: 'デフォルトでテキスト表示',
|
||||
settings_label_structured_code_auto_lines: '自動切り替えのしきい値行数',
|
||||
settings_desc_structured_code: '「自動」が選択されている場合、指定した行数以上のJSON/YAMLブロックはツリービューで開きます。ツリーは構造化ビューアで開き、テキスト(Raw)は構文強調表示のテキストとして残ります。ブロックごとにツリー/テキストを切り替えることも可能です。',
|
||||
settings_label_worklog_details_expanded_default: 'Worklog の詳細を自動的に開く',
|
||||
settings_desc_worklog_details_expanded_default: '有効にすると、新しい Worklog の詳細が展開された状態で始まり、ツール、Thinking、進捗カードをクリックなしで確認できます。無効の場合、Worklog の詳細はデフォルトで折りたたまれます。ターンごとの手動折りたたみ/展開は優先されます。',
|
||||
settings_label_chat_activity_display_mode: 'アクティビティ表示',
|
||||
settings_desc_chat_activity_display_mode: '「コンパクトワークログ」はバックグラウンドアクティビティを簡潔なサマリーにまとめます。「トランスペアレントストリーム」は思考プロセスやツールの呼び出しイベントを、時系列順に展開可能な個別の行として表示し続けます。 Final answer only hides activity rows while keeping the final answer visible.',
|
||||
settings_label_transparent_stream_event_timestamps: 'Show Transparent Stream event timestamps',
|
||||
settings_desc_transparent_stream_event_timestamps: 'Keeps the response footer time visible while hiding the per-event timestamp chips.',
|
||||
settings_label_transparent_stream_event_timestamps: 'Transparent Stream のイベントタイムスタンプを表示',
|
||||
settings_desc_transparent_stream_event_timestamps: '応答フッターの時刻表示は維持したまま、イベントごとのタイムスタンプチップを非表示にします。',
|
||||
first_token_time: '最初のトークン出力までの時間',
|
||||
processed_elapsed: _i18nProcessedElapsedEn,
|
||||
worklog_thinking: 'Thinking',
|
||||
tool_target_skill_suffix: 'skill',
|
||||
worklog_thinking: '思考中',
|
||||
tool_target_skill_suffix: 'スキル',
|
||||
tool_action_label: _i18nToolActionLabelEn,
|
||||
tool_worklog_summary: _i18nToolWorklogSummaryEn,
|
||||
tool_summary_join: _i18nToolSummaryJoinEn,
|
||||
done: '完了',
|
||||
settings_option_compact_worklog: 'コンパクトワークログ',
|
||||
settings_option_transparent_stream: 'トランスペアレントストリーム',
|
||||
settings_option_final_answer_only: 'Final answer only',
|
||||
settings_option_final_answer_only: '最終回答のみ',
|
||||
|
||||
settings_label_tab_visibility: 'サイドバータブ',
|
||||
settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。',
|
||||
settings_label_composer_controls: 'Chat footer controls',
|
||||
settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.',
|
||||
settings_label_composer_situational_controls: 'Situational footer controls',
|
||||
settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.',
|
||||
composer_control_attach: 'Attach',
|
||||
composer_control_saved_prompts: 'Saved prompts',
|
||||
composer_control_mic: 'Mic',
|
||||
composer_control_profile: 'Profile',
|
||||
composer_control_workspace: 'Workspace',
|
||||
composer_control_model: 'Model',
|
||||
composer_control_reasoning: 'Reasoning',
|
||||
composer_control_context: 'Context',
|
||||
composer_control_voice_mode: 'Voice mode',
|
||||
settings_label_composer_controls: 'チャットフッターのコントロール',
|
||||
settings_desc_composer_controls: 'チャットのフッターに表示するコントロールを切り替えます。チップをドラッグして各グループの並び順を変更できます。',
|
||||
settings_label_composer_situational_controls: '状況に応じたフッターコントロール',
|
||||
settings_desc_composer_situational_controls: 'これらのコントロールは、表示領域やモード、実行状態に応じて常に表示されるとは限りません。不要な場合はオフに切り替えることができます。',
|
||||
composer_control_attach: '添付',
|
||||
composer_control_saved_prompts: '保存されたプロンプト',
|
||||
composer_control_mic: 'マイク',
|
||||
composer_control_profile: 'プロファイル',
|
||||
composer_control_workspace: 'ワークスペース',
|
||||
composer_control_model: 'モデル',
|
||||
composer_control_reasoning: '思考',
|
||||
composer_control_context: 'コンテキスト',
|
||||
composer_control_voice_mode: '音声モード',
|
||||
composer_control_yolo: 'YOLO',
|
||||
composer_control_bg_badge: 'Background badge',
|
||||
composer_control_mobile_config: 'Mobile config',
|
||||
composer_control_quota_chip: 'Quota chip',
|
||||
composer_control_toolsets: 'Toolsets',
|
||||
composer_control_status: 'Status',
|
||||
composer_control_bg_badge: 'バックグラウンドバッジ',
|
||||
composer_control_mobile_config: 'モバイル設定',
|
||||
composer_control_quota_chip: 'クォータ',
|
||||
composer_control_toolsets: 'ツールセット',
|
||||
composer_control_status: 'ステータス',
|
||||
open_in_browser: 'ブラウザで開く',
|
||||
settings_dropdown_conversation: '会話',
|
||||
settings_dropdown_appearance: '外観',
|
||||
@@ -4224,24 +4232,24 @@ const LOCALES = {
|
||||
settings_tab_preferences: '環境設定',
|
||||
settings_tab_plugins: 'プラグイン',
|
||||
settings_tab_extensions: '拡張機能',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
settings_extensions_gallery_tab: 'ギャラリー',
|
||||
settings_extensions_installed_tab: 'インストール済み',
|
||||
settings_extensions_diagnostics_tab: '診断',
|
||||
settings_extensions_gallery_loading: 'ギャラリーを読み込み中…',
|
||||
settings_extensions_gallery_empty: 'レジストリ内に拡張機能が見つかりません。',
|
||||
settings_extensions_gallery_error: 'ギャラリーの読み込みに失敗しました。',
|
||||
ext_gallery_install: 'インストール',
|
||||
ext_gallery_installing: 'インストール中…',
|
||||
ext_gallery_installed: 'インストール済み',
|
||||
ext_gallery_uninstall: 'アンインストール',
|
||||
ext_gallery_uninstalling: 'アンインストール中…',
|
||||
ext_gallery_restart_required: '再起動が必要',
|
||||
ext_gallery_permissions_show: '権限',
|
||||
ext_gallery_permissions_hide: '権限を非表示',
|
||||
ext_gallery_permissions_empty: '追加の権限は宣言されていません。',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_version: 'バージョン',
|
||||
ext_gallery_author: '作成者',
|
||||
ext_gallery_capabilities: '機能',
|
||||
ext_gallery_next_step: '次の手順',
|
||||
ext_gallery_after_install: 'インストール後',
|
||||
ext_gallery_local_component_required: 'この拡張機能にはローカルコンポーネントが必要です。インストール後に必要なローカルアプリまたはサイドカーを起動してください。',
|
||||
@@ -4252,8 +4260,8 @@ const LOCALES = {
|
||||
ext_gallery_open_setup_guide: 'セットアップガイドを開く',
|
||||
ext_gallery_install_restart_required: '拡張機能をインストールしました。適用するには再起動が必要です。',
|
||||
ext_gallery_install_followup: '拡張機能をインストールしました。次の手順はカードで確認してください。',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
ext_gallery_install_ok: '拡張機能が正常にインストールされました。',
|
||||
ext_gallery_uninstall_ok: '拡張機能がアンインストールされました。',
|
||||
settings_plugins_title: 'プラグイン',
|
||||
plugins_enable_toggle: '有効化',
|
||||
settings_plugins_meta: 'インストール済みのHermesプラグインと登録されているライフサイクルフックを確認できます。このパネルは読み取り専用です。',
|
||||
@@ -4287,10 +4295,10 @@ const LOCALES = {
|
||||
settings_main_advanced_subtitle: 'デフォルトのチャットモデルに関する詳細設定です。',
|
||||
settings_main_advanced_saved: 'メインモデルのオプションを保存しました',
|
||||
settings_main_advanced_save_failed: 'メインモデルのオプションの保存に失敗しました',
|
||||
settings_main_advanced_service_tier: 'Service tier',
|
||||
settings_main_advanced_service_tier_desc: 'Optional request setting for OpenAI-family providers.',
|
||||
settings_main_advanced_service_tier_default: 'Default / off',
|
||||
settings_main_advanced_service_tier_priority: 'Priority (fast)',
|
||||
settings_main_advanced_service_tier: 'サービスティア',
|
||||
settings_main_advanced_service_tier_desc: 'OpenAIファミリー系プロバイダー向けのリクエスト設定(オプション)です。',
|
||||
settings_main_advanced_service_tier_default: 'デフォルト / オフ',
|
||||
settings_main_advanced_service_tier_priority: '優先(高速)',
|
||||
settings_label_auxiliary_models: '補助モデル',
|
||||
settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。',
|
||||
settings_btn_reset_aux_models: 'すべて自動にリセット',
|
||||
@@ -4371,15 +4379,17 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: '現在の会話内のユーザー質問へ移動するデスクトップ専用リストを表示します。デフォルトはオフです。',
|
||||
settings_label_hide_suggestions: '新規チャットの候補を非表示',
|
||||
settings_desc_hide_suggestions: '空の新規チャット画面に表示されるデフォルトの候補ボタン3つを非表示にして、誤タップを防ぎます。',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_hide_empty_state_panel: '新規チャットのウェルカムパネルを非表示',
|
||||
settings_desc_hide_empty_state_panel: '空の新規会話でロゴ、見出し、サブタイトル、候補ボタンを非表示にします。',
|
||||
settings_label_virtualize_transcript: '長い会話履歴を仮想化する(実験的)',
|
||||
settings_desc_virtualize_transcript: '実験的機能です。非常に長いチャット履歴(80メッセージ以上)の表示領域のみをレンダリングし、スクロールを高速化します。大きなツール呼び出しブロックを含む長いセッションでスクロール時にちらつきが発生することがあるため、デフォルトではオフになっています。試す場合はオンにしてください。オフのままにすると履歴全体が一度にレンダリングされます(ブラウザの検索機能(Ctrl+F)でも会話全体を検索できます)。',
|
||||
settings_label_token_usage: 'トークン使用量を表示',
|
||||
settings_label_token_speed: 'トークン速度 (TPS) を表示',
|
||||
settings_desc_token_speed: 'ストリーミング中および応答完了後にアシスタントメッセージヘッダーにトークン/秒を表示します。デフォルトではオフ。',
|
||||
settings_label_sidebar_density: 'サイドバー密度',
|
||||
cmd_reasoning: '思考表示の切り替え (表示/非表示)、努力レベル設定、現在状態の確認',
|
||||
settings_label_external_sessions: '非WebUIセッションを表示',
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_claude_code_sessions: 'Claude Code セッションを表示',
|
||||
settings_label_cron_sessions: 'Cronセッションを表示',
|
||||
settings_label_webhook_sessions: 'Webhookセッションを表示',
|
||||
settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示',
|
||||
@@ -4448,8 +4458,8 @@ const LOCALES = {
|
||||
kanban_no_matching_tasks: '一致するタスクがありません',
|
||||
kanban_no_data: 'カンバンデータがありません',
|
||||
kanban_work_queue_hint: 'これは Hermes Agent のワークキューです。タスクを作成またはトリアージし、担当者を割り当て、Ready に移動すると、ディスパッチャーがそれをクレームします。',
|
||||
kanban_tasks_hidden_by_filters: 'Tasks hidden by filters',
|
||||
kanban_clear_filters: 'Clear filters',
|
||||
kanban_tasks_hidden_by_filters: 'フィルターによって非表示のタスク',
|
||||
kanban_clear_filters: 'フィルターをクリア',
|
||||
kanban_unavailable: 'カンバンを利用できません',
|
||||
kanban_read_only: '読み取り専用',
|
||||
kanban_empty: '空',
|
||||
@@ -4507,24 +4517,24 @@ const LOCALES = {
|
||||
kanban_view_consolidated_saved: 'Kanban 表示: 統合ビュー',
|
||||
kanban_view_update_failed: 'Kanban 表示の更新に失敗しました: ',
|
||||
kanban_add_comment: 'コメント追加',
|
||||
kanban_workspace_kind: 'Workspace kind',
|
||||
kanban_workspace_path: 'Workspace path',
|
||||
kanban_workspace_path_placeholder: 'Path to workspace',
|
||||
kanban_workspace_scratch: 'Scratch',
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
kanban_dependency_self: 'A task cannot depend on itself',
|
||||
kanban_workspace_kind: 'ワークスペースの種類',
|
||||
kanban_workspace_path: 'ワークスペースのパス',
|
||||
kanban_workspace_path_placeholder: 'ワークスペースへのパス',
|
||||
kanban_workspace_scratch: 'スクラッチ',
|
||||
kanban_workspace_worktree: 'ワークツリー',
|
||||
kanban_workspace_dir: 'ディレクトリ',
|
||||
kanban_workspace_path_required: 'スクラッチ以外のワークスペースではパスの指定が必須です。',
|
||||
kanban_skills: 'スキル',
|
||||
kanban_skills_placeholder: 'カンマ区切りのスキル名(例: python,git)',
|
||||
kanban_max_runtime_seconds: '最大実行時間(秒)',
|
||||
kanban_max_runtime_hint: '無制限にする場合は空欄にします。',
|
||||
kanban_max_runtime_invalid: '最大実行時間は0より大きい整数値である必要があります。',
|
||||
kanban_parents_modal: '依存元の親タスク',
|
||||
kanban_parents_placeholder: '親タスクID',
|
||||
kanban_add_dependency: '依存関係を追加',
|
||||
kanban_remove_dependency: '依存関係を削除',
|
||||
kanban_dependency_placeholder: 'リンクするタスクID',
|
||||
kanban_dependency_self: 'タスクが自分自身に依存することはできません',
|
||||
kanban_only_mine: '自分のみ',
|
||||
kanban_bulk_action: '一括操作',
|
||||
kanban_nudge_dispatcher: 'ディスパッチャープレビュー',
|
||||
@@ -4604,7 +4614,7 @@ const LOCALES = {
|
||||
insights_messages: 'メッセージ',
|
||||
insights_tokens: 'トークン',
|
||||
insights_cost: '推定コスト',
|
||||
insights_no_cost: 'N/A',
|
||||
insights_no_cost: '該当なし',
|
||||
insights_models: 'モデル',
|
||||
insights_activity_by_day: '日別アクティビティ',
|
||||
insights_activity_by_hour: '時間別アクティビティ',
|
||||
@@ -4705,7 +4715,7 @@ const LOCALES = {
|
||||
settings_auto_title_refresh_20: '20 回ごと',
|
||||
settings_desc_auto_title_refresh: '最新のやり取りに基づいてセッションタイトルを自動再生成し、会話の進行に合わせて適切に保ちます。LLM タイトル生成モデルの設定が必要です。',
|
||||
settings_desc_external_sessions: 'CLI、Telegram、Discord、Slack その他のチャネルからの会話をセッション一覧に表示します。クリックでインポートして続行できます。',
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_claude_code_sessions: 'サイドバーに Claude Code からインポートされたセッションを表示します。または、他の外部セッションは表示したまま Claude Code のセッションのみを非表示にします。',
|
||||
settings_desc_cron_sessions: 'Cronジョブの出力をサイドバーの会話として表示します。WebUI以外のセッションが有効な場合のみ機能します。デフォルトはオフ。高頻度のジョブはサイドバーを溢れさせる可能性があります。',
|
||||
settings_desc_webhook_sessions: 'Webhook実行をサイドバーの会話として表示します。WebUI以外のセッションが有効な場合のみ機能します。デフォルトはオフ。高頻度のルートはサイドバーを溢れさせる可能性があります。',
|
||||
settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。',
|
||||
@@ -4791,12 +4801,12 @@ const LOCALES = {
|
||||
provider_quota_pool_summary_exhausted: '{0} 使い切り済み',
|
||||
provider_quota_pool_summary_failed: '{0} 失敗',
|
||||
provider_quota_pool_summary_checked: '{0} 確認済み',
|
||||
provider_cost_budget_label: 'Monthly budget',
|
||||
provider_cost_budget_placeholder: 'e.g. 50.00',
|
||||
provider_cost_budget_set: 'Set',
|
||||
provider_cost_budget_clear: 'Clear',
|
||||
provider_cost_budget_pct: '{0}% of ${1} budget (monthly pace)',
|
||||
provider_cost_budget_save_failed: 'Failed to save budget',
|
||||
provider_cost_budget_label: '月間予算',
|
||||
provider_cost_budget_placeholder: '例: 50.00',
|
||||
provider_cost_budget_set: '設定',
|
||||
provider_cost_budget_clear: 'クリア',
|
||||
provider_cost_budget_pct: '予算 ${1} の {0}%(月換算ペース)',
|
||||
provider_cost_budget_save_failed: '予算の保存に失敗しました',
|
||||
cancel: 'キャンセル',
|
||||
create_job: 'ジョブを作成',
|
||||
save_skill: 'スキルを保存',
|
||||
@@ -4921,7 +4931,7 @@ const LOCALES = {
|
||||
error_prefix: 'エラー: ',
|
||||
default: 'デフォルト',
|
||||
search: '検索',
|
||||
not_available: 'N/A',
|
||||
not_available: '利用不可',
|
||||
never: 'なし',
|
||||
add: '追加',
|
||||
add_failed: '追加失敗: ',
|
||||
@@ -4985,7 +4995,7 @@ const LOCALES = {
|
||||
cron_job_updated: 'ジョブを更新しました',
|
||||
cron_model_label: 'モデルの上書き',
|
||||
cron_model_hint: 'このジョブのデフォルトモデルを上書きします。',
|
||||
cron_model_no_agent_hint: 'No-agent jobs run the configured script directly; model is unused.',
|
||||
cron_model_no_agent_hint: 'エージェントなしのジョブは設定済みスクリプトを直接実行します。モデルは使用されません。',
|
||||
cron_model_use_default: 'デフォルト(プロファイル/システムの既定を使用)',
|
||||
cron_delete_confirm_title: 'Cronジョブを削除',
|
||||
cron_delete_confirm_message: 'この操作は取り消せません。',
|
||||
@@ -5136,18 +5146,18 @@ const LOCALES = {
|
||||
cron_schedule_preset_weekly: '毎週',
|
||||
cron_schedule_preset_monthly: '毎月',
|
||||
cron_schedule_preset_custom: 'カスタム',
|
||||
cron_schedule_time_label: 'Time',
|
||||
cron_schedule_conj_on: 'on',
|
||||
cron_schedule_conj_on_day: 'on day',
|
||||
cron_schedule_conj_at: 'at',
|
||||
cron_schedule_conj_at_minute: 'at minute',
|
||||
cron_weekday_sun: 'Sunday',
|
||||
cron_weekday_mon: 'Monday',
|
||||
cron_weekday_tue: 'Tuesday',
|
||||
cron_weekday_wed: 'Wednesday',
|
||||
cron_weekday_thu: 'Thursday',
|
||||
cron_weekday_fri: 'Friday',
|
||||
cron_weekday_sat: 'Saturday',
|
||||
cron_schedule_time_label: '時刻',
|
||||
cron_schedule_conj_on: '曜日',
|
||||
cron_schedule_conj_on_day: '日',
|
||||
cron_schedule_conj_at: '時刻',
|
||||
cron_schedule_conj_at_minute: '分',
|
||||
cron_weekday_sun: '日曜日',
|
||||
cron_weekday_mon: '月曜日',
|
||||
cron_weekday_tue: '火曜日',
|
||||
cron_weekday_wed: '水曜日',
|
||||
cron_weekday_thu: '木曜日',
|
||||
cron_weekday_fri: '金曜日',
|
||||
cron_weekday_sat: '土曜日',
|
||||
cron_schedule_hour_label: '時',
|
||||
cron_schedule_minute_label: '分',
|
||||
cron_schedule_weekday_label: '曜日 (0-6, 日-土)',
|
||||
@@ -5240,10 +5250,10 @@ const LOCALES = {
|
||||
outline_title: 'アウトライン',
|
||||
outline_empty: 'まだ質問はありません。',
|
||||
outline_loading: '読み込み中…',
|
||||
wiki_browse: 'Browse wiki',
|
||||
wiki_search_placeholder: 'Search pages...',
|
||||
wiki_no_pages: 'No wiki pages found',
|
||||
wiki_not_configured: 'Wiki not configured',
|
||||
wiki_browse: 'Wikiを閲覧',
|
||||
wiki_search_placeholder: 'ページを検索...',
|
||||
wiki_no_pages: 'Wikiページが見つかりません',
|
||||
wiki_not_configured: 'Wikiが設定されていません',
|
||||
},
|
||||
|
||||
ru: {
|
||||
@@ -5258,7 +5268,7 @@ const LOCALES = {
|
||||
_label: 'Русский',
|
||||
_speech: 'ru-RU',
|
||||
cancelling: 'Отменяю…',
|
||||
cancel_failed: 'Не удалось отменить: ',
|
||||
cancel_failed: 'Не удалось отменить.',
|
||||
mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.',
|
||||
mic_insecure_origin: 'Для голосового ввода нужно защищённое соединение. Откройте Hermes по HTTPS или с localhost, чтобы использовать микрофон.',
|
||||
mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.',
|
||||
@@ -5390,6 +5400,7 @@ const LOCALES = {
|
||||
approval_responding: 'Отвечаю…',
|
||||
approval_gateway_unsupported: 'Для подтверждений нужен более новый Gateway. Обновите подключённый Hermes Gateway, чтобы включить их.',
|
||||
approval_gateway_unsupported_label: 'Одобрение не поддерживается',
|
||||
approval_pending_count: (n) => `1 из ${n} в ожидании`,
|
||||
untitled: 'Без названия',
|
||||
n_messages: (n) => `${n} сообщений`,
|
||||
load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения',
|
||||
@@ -5815,6 +5826,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Показывает на рабочем столе список переходов к вопросам пользователя в текущей беседе. По умолчанию отключено.',
|
||||
settings_label_hide_suggestions: 'Скрывать подсказки нового чата',
|
||||
settings_desc_hide_suggestions: 'Скрывает три стандартные кнопки-подсказки на пустом экране нового чата, чтобы избежать случайных нажатий.',
|
||||
settings_label_hide_empty_state_panel: 'Скрывать приветственную панель нового чата',
|
||||
settings_desc_hide_empty_state_panel: 'Скрывает логотип, заголовок, подзаголовок и кнопки-подсказки в пустой новой беседе.',
|
||||
settings_label_virtualize_transcript: 'Оптимизировать длинные транскрипты',
|
||||
settings_desc_virtualize_transcript: 'Отрисовывает только видимую часть очень длинных транскриптов чата (более 80 сообщений), чтобы прокрутка была плавнее. Отключите, если заметите проблемы с отрисовкой или прокруткой либо хотите, чтобы поиск браузера (Ctrl+F) находил текст во всей беседе — тогда транскрипт будет отрисован целиком.',
|
||||
settings_label_token_usage: 'Показывать использование токенов',
|
||||
@@ -6978,7 +6991,7 @@ const LOCALES = {
|
||||
_speech: 'es-ES',
|
||||
// boot.js
|
||||
cancelling: 'Cancelando…',
|
||||
cancel_failed: 'Error al cancelar: ',
|
||||
cancel_failed: 'Error al cancelar.',
|
||||
mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.',
|
||||
mic_insecure_origin: 'La entrada de voz necesita una conexión segura. Abre Hermes con HTTPS o desde localhost para usar el micrófono.',
|
||||
mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.',
|
||||
@@ -7098,6 +7111,7 @@ const LOCALES = {
|
||||
approval_responding: 'Respondiendo…',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: 'Las aprobaciones no son compatibles',
|
||||
approval_pending_count: (n) => `1 de ${n} pendientes`,
|
||||
clarify_heading: 'Se necesita aclaración',
|
||||
clarify_hint: 'Elige una opción o escribe tu propia respuesta abajo.',
|
||||
clarify_other: 'Otra',
|
||||
@@ -7508,6 +7522,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Muestra una lista de salto solo en escritorio para las preguntas del usuario en la conversación actual. Desactivado de forma predeterminada.',
|
||||
settings_label_hide_suggestions: 'Hide new-chat suggestions',
|
||||
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
|
||||
settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel',
|
||||
settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||
@@ -8664,7 +8680,7 @@ const LOCALES = {
|
||||
_speech: 'de-DE',
|
||||
// boot.js
|
||||
cancelling: 'Wird abgebrochen\u2026',
|
||||
cancel_failed: 'Abbrechen fehlgeschlagen: ',
|
||||
cancel_failed: 'Abbrechen fehlgeschlagen.',
|
||||
mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.',
|
||||
mic_insecure_origin: 'Für die Spracheingabe ist eine sichere Verbindung erforderlich. Öffne Hermes über HTTPS oder von localhost, um das Mikrofon zu verwenden.',
|
||||
mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.',
|
||||
@@ -8784,6 +8800,7 @@ const LOCALES = {
|
||||
approval_responding: 'Antwortet\u2026',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: 'Genehmigungen nicht unterstützt',
|
||||
approval_pending_count: (n) => `1 von ${n} ausstehend`,
|
||||
clarify_heading: 'Klärung erforderlich',
|
||||
clarify_hint: 'Wähle eine Option oder schreibe deine eigene Antwort unten.',
|
||||
clarify_other: 'Andere',
|
||||
@@ -9170,6 +9187,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Zeigt eine Desktop-Sprungliste für Benutzerfragen in der aktuellen Konversation. Standardmäßig deaktiviert.',
|
||||
settings_label_hide_suggestions: 'Hide new-chat suggestions',
|
||||
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
|
||||
settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel',
|
||||
settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||
@@ -10344,7 +10363,7 @@ const LOCALES = {
|
||||
_speech: 'zh-CN',
|
||||
// boot.js
|
||||
cancelling: '正在取消...',
|
||||
cancel_failed: '取消失败:',
|
||||
cancel_failed: '取消失败。',
|
||||
mic_denied: '麦克风访问被拒绝,请检查浏览器权限。',
|
||||
mic_insecure_origin: '语音输入需要安全连接。请通过 HTTPS 协议或从本地主机打开Hermes,以便使用麦克风。',
|
||||
mic_no_speech: '没有检测到语音,请再试一次。',
|
||||
@@ -10464,6 +10483,7 @@ const LOCALES = {
|
||||
approval_responding: '处理中…',
|
||||
approval_gateway_unsupported: '批准操作需要更新版本的网关。请升级已连接的Hermes网关以实现此功能。',
|
||||
approval_gateway_unsupported_label: '不支持批准',
|
||||
approval_pending_count: (n) => `${n} 个待处理中的第 1 个`,
|
||||
clarify_heading: '需要澄清',
|
||||
clarify_hint: '请选择一个选项,或在下方输入你自己的回答。',
|
||||
clarify_other: '其他',
|
||||
@@ -10893,6 +10913,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: '显示仅限桌面的跳转列表,用于跳转到当前会话中的用户问题。默认关闭。',
|
||||
settings_label_hide_suggestions: '隐藏新聊天建议',
|
||||
settings_desc_hide_suggestions: '隐藏新聊天空白界面上的三个默认建议按钮,以避免误触。',
|
||||
settings_label_hide_empty_state_panel: '隐藏新聊天欢迎面板',
|
||||
settings_desc_hide_empty_state_panel: '隐藏空白新对话中的徽标、标题、副标题和建议按钮。',
|
||||
settings_label_virtualize_transcript: '对长篇聊天记录进行虚拟处理',
|
||||
settings_desc_virtualize_transcript: "对于非常长的聊天记录(超过80条消息),仅渲染可见部分,以实现更流畅的滚动。如果遇到任何渲染或滚动问题,或者希望浏览器的查找(Ctrl + F)匹配整个对话,请关闭此功能 \u2014 这样完整的聊天记录将一次性渲染出来。",
|
||||
settings_label_token_usage: '显示 token 用量',
|
||||
@@ -12019,7 +12041,7 @@ const LOCALES = {
|
||||
_speech: 'zh-TW',
|
||||
// boot.js
|
||||
cancelling: '正在取消……',
|
||||
cancel_failed: '取消失敗:',
|
||||
cancel_failed: '取消失敗。',
|
||||
mic_denied: '麥克風存取遭拒。請檢查瀏覽器權限。',
|
||||
mic_insecure_origin: '語音輸入需要安全連線。請透過 HTTPS 或 localhost 開啟 Hermes 才能使用麥克風。',
|
||||
mic_no_speech: '未偵測到語音。請再試一次。',
|
||||
@@ -12167,6 +12189,7 @@ const LOCALES = {
|
||||
approval_responding: '正在回應……',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: '不支援核准',
|
||||
approval_pending_count: (n) => `${n} 個待處理中的第 1 個`,
|
||||
clarify_heading: '需要釐清',
|
||||
clarify_hint: '請選擇一個選項,或在下方輸入回答。',
|
||||
clarify_other: '其他',
|
||||
@@ -12869,6 +12892,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: '顯示僅限桌面的跳轉清單,用於跳轉到目前對話中的使用者問題。預設關閉。',
|
||||
settings_label_hide_suggestions: '隱藏新聊天建議',
|
||||
settings_desc_hide_suggestions: '隱藏空白新聊天畫面上的三個預設建議按鈕,避免誤觸。',
|
||||
settings_label_hide_empty_state_panel: '隱藏新聊天歡迎面板',
|
||||
settings_desc_hide_empty_state_panel: '隱藏空白新對話中的標誌、標題、副標題和建議按鈕。',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_token_usage: '顯示 token 用量',
|
||||
@@ -13761,7 +13786,7 @@ const LOCALES = {
|
||||
_speech: 'pt-BR',
|
||||
// boot.js
|
||||
cancelling: 'Cancelando…',
|
||||
cancel_failed: 'Falha ao cancelar: ',
|
||||
cancel_failed: 'Falha ao cancelar.',
|
||||
mic_denied: 'Acesso ao microfone negado. Verifique as permissões do navegador.',
|
||||
mic_insecure_origin: 'A entrada de voz precisa de uma conexão segura. Abra o Hermes por HTTPS ou a partir do localhost para usar o microfone.',
|
||||
mic_no_speech: 'Nenhuma fala detectada. Tente novamente.',
|
||||
@@ -13815,6 +13840,7 @@ const LOCALES = {
|
||||
approval_responding: 'Respondendo…',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: 'Aprovações não suportadas',
|
||||
approval_pending_count: (n) => `1 de ${n} pendentes`,
|
||||
clarify_heading: 'Esclarecimento necessário',
|
||||
clarify_hint: 'Escolha uma opção ou digite sua resposta abaixo.',
|
||||
clarify_other: 'Outro',
|
||||
@@ -14502,6 +14528,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Mostra uma lista de atalhos somente no desktop para as perguntas do usuário na conversa atual. Desativado por padrão.',
|
||||
settings_label_hide_suggestions: 'Hide new-chat suggestions',
|
||||
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
|
||||
settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel',
|
||||
settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||
@@ -15321,7 +15349,7 @@ const LOCALES = {
|
||||
_speech: 'ko-KR',
|
||||
// boot.js
|
||||
cancelling: '취소 중\u2026',
|
||||
cancel_failed: '취소 실패: ',
|
||||
cancel_failed: '취소에 실패했습니다.',
|
||||
mic_denied: '마이크 접근이 거부되었습니다. 브라우저 권한을 확인하세요.',
|
||||
mic_insecure_origin: '음성 입력에는 보안 연결이 필요합니다. 마이크를 사용하려면 HTTPS 또는 localhost를 통해 Hermes를 여세요.',
|
||||
mic_no_speech: '음성이 감지되지 않았습니다. 다시 시도하세요.',
|
||||
@@ -15441,6 +15469,7 @@ const LOCALES = {
|
||||
approval_responding: '응답 중\u2026',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: '승인이 지원되지 않음',
|
||||
approval_pending_count: (n) => `${n}개 중 1개 대기 중`,
|
||||
clarify_heading: '확인 필요',
|
||||
clarify_hint: '선택지를 고르거나 아래에 직접 입력하세요.',
|
||||
clarify_other: '기타',
|
||||
@@ -16141,6 +16170,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: '현재 대화의 사용자 질문으로 이동하는 데스크톱 전용 목록을 표시합니다. 기본값은 꺼짐입니다.',
|
||||
settings_label_hide_suggestions: 'Hide new-chat suggestions',
|
||||
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
|
||||
settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel',
|
||||
settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
settings_label_token_usage: '토큰 사용량 표시',
|
||||
@@ -17050,7 +17081,7 @@ const LOCALES = {
|
||||
_label: 'Français',
|
||||
_speech: 'fr-FR',
|
||||
cancelling: 'Annulation\u2026',
|
||||
cancel_failed: 'Échec de l\'annulation : ',
|
||||
cancel_failed: 'Échec de l\'annulation.',
|
||||
mic_denied: 'Accès au microphone refusé. Vérifiez les autorisations du navigateur.',
|
||||
mic_insecure_origin: 'La saisie vocale nécessite une connexion sécurisée. Ouvrez Hermes en HTTPS ou depuis localhost pour utiliser le microphone.',
|
||||
mic_no_speech: 'Aucune parole détectée. Réessayez.',
|
||||
@@ -17191,6 +17222,7 @@ const LOCALES = {
|
||||
approval_responding: 'Réponse en cours\u2026',
|
||||
approval_gateway_unsupported: 'Les approbations nécessitent une passerelle plus récente. Mettez à niveau la passerelle Hermes connectée pour activer cette fonctionnalité.',
|
||||
approval_gateway_unsupported_label: 'Approbations non prises en charge',
|
||||
approval_pending_count: (n) => `1 sur ${n} en attente`,
|
||||
clarify_heading: 'Clarification nécessaire',
|
||||
clarify_hint: 'Choisissez une option ou saisissez votre propre réponse ci-dessous.',
|
||||
clarify_other: 'Autre',
|
||||
@@ -17895,6 +17927,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Afficher une liste de saut réservée au bureau pour les questions utilisateur dans la conversation actuelle. Désactivé par défaut.',
|
||||
settings_label_hide_suggestions: 'Masquer les suggestions de nouvelle conversation',
|
||||
settings_desc_hide_suggestions: 'Masquer les trois boutons de suggestion par défaut sur l\'écran de nouvelle conversation vide pour éviter les clics accidentels.',
|
||||
settings_label_hide_empty_state_panel: 'Masquer le panneau de bienvenue des nouvelles conversations',
|
||||
settings_desc_hide_empty_state_panel: 'Masquer le logo, le titre, le sous-titre et les boutons de suggestion dans une nouvelle conversation vide.',
|
||||
settings_label_virtualize_transcript: 'Virtualiser les longs historiques (expérimental)',
|
||||
settings_desc_virtualize_transcript: 'Expérimental. Affiche uniquement la partie visible des historiques de conversation très longs (plus de 80 messages) pour accélérer le défilement. Désactivé par défaut car cela peut causer des scintillements lors du défilement sur de longues sessions avec de gros blocs d\'appels d\'outils. Activez-le pour essayer ; désactivez-le pour afficher l\'intégralité de l\'historique à la fois (et pour que la recherche du navigateur (Ctrl+F) corresponde à toute la conversation).',
|
||||
settings_label_token_usage: 'Afficher l\'utilisation des jetons',
|
||||
@@ -19740,6 +19774,8 @@ const LOCALES = {
|
||||
settings_label_font_size: 'Velikost písma',
|
||||
settings_label_gateway_status: 'Stav Gateway',
|
||||
settings_label_hide_suggestions: 'Skrýt návrhy nového chatu',
|
||||
settings_label_hide_empty_state_panel: 'Skrýt uvítací panel nového chatu',
|
||||
settings_desc_hide_empty_state_panel: 'Skrýt logo, nadpis, podnadpis a tlačítka návrhů v prázdné nové konverzaci.',
|
||||
settings_label_ignore_agent_updates: 'Ignorovat aktualizace Agenta',
|
||||
settings_label_large_text_paste_as_attachment: 'Přiložit velký vložený text jako soubor',
|
||||
settings_label_notifications: 'Notifikace prohlížeče',
|
||||
@@ -19955,6 +19991,7 @@ const LOCALES = {
|
||||
approval_desc_prefix: 'Byl zjištěn nebezpečný příkaz',
|
||||
approval_gateway_unsupported: 'Schválení vyžadují novější gateway. Upgradujte připojený Hermes gateway.',
|
||||
approval_gateway_unsupported_label: 'Schválení nejsou podporována',
|
||||
approval_pending_count: (n) => `1 z ${n} čeká`,
|
||||
approval_heading: 'Požadováno schválení',
|
||||
approval_responding: 'Reakce\u2026',
|
||||
auto_compress_label: 'Automatická komprese',
|
||||
@@ -19975,7 +20012,7 @@ const LOCALES = {
|
||||
btw_no_answer: 'Nebyla přijata žádná odpověď.',
|
||||
busy_interrupt_confirm: 'Přerušeno — odeslání nové zprávy',
|
||||
busy_steer_fallback: 'Řízení není k dispozici — návrh byl obnoven',
|
||||
cancel_failed: 'Zrušení selhalo: ',
|
||||
cancel_failed: 'Zrušení selhalo.',
|
||||
cancel_unavailable: 'Zrušení není k dispozici.',
|
||||
cancelling: 'Rušení\u2026',
|
||||
clarify_heading: 'Zde je zapotřebí vyjasnění.',
|
||||
@@ -20467,7 +20504,7 @@ const LOCALES = {
|
||||
_speech: 'tr-TR',
|
||||
// boot.js
|
||||
cancelling: 'İptal ediliyor\u2026',
|
||||
cancel_failed: 'İptal başarısız oldu:',
|
||||
cancel_failed: 'İptal başarısız oldu.',
|
||||
mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.',
|
||||
mic_insecure_origin: 'Sesli giriş güvenli bir bağlantı gerektirir. Mikrofonu kullanmak için Hermes\'i HTTPS üzerinden veya localhost\'tan açın.',
|
||||
mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.',
|
||||
@@ -20604,6 +20641,7 @@ const LOCALES = {
|
||||
approval_responding: 'Yanıt veriliyor\u2026',
|
||||
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
|
||||
approval_gateway_unsupported_label: 'Onaylar desteklenmiyor',
|
||||
approval_pending_count: (n) => `${n} bekleyenden 1'i`,
|
||||
clarify_heading: 'Açıklama gerekli',
|
||||
clarify_hint: 'Bir seçim yapın veya kendi cevabınızı aşağıya yazın.',
|
||||
clarify_other: 'Diğer',
|
||||
@@ -21301,6 +21339,8 @@ const LOCALES = {
|
||||
settings_label_conversation_outline: 'Konuşma ana hattını göster',
|
||||
settings_desc_conversation_outline: 'Geçerli konuşmadaki kullanıcı soruları için yalnızca masaüstünde görünen bir atlama listesi gösterir. Varsayılan kapalıdır.',
|
||||
settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle',
|
||||
settings_label_hide_empty_state_panel: 'Yeni sohbet karşılama panelini gizle',
|
||||
settings_desc_hide_empty_state_panel: 'Boş bir yeni konuşmadaki logoyu, başlığı, alt başlığı ve öneri düğmelerini gizleyin.',
|
||||
settings_desc_hide_suggestions: 'Yanlışlıkla dokunmayı önlemek için boş yeni sohbet ekranındaki üç varsayılan öneri düğmesini gizleyin.',
|
||||
settings_label_virtualize_transcript: 'Virtualize long transcripts',
|
||||
settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.",
|
||||
@@ -22198,7 +22238,7 @@ const LOCALES = {
|
||||
_speech: 'pl-PL',
|
||||
// boot.js
|
||||
cancelling: 'Anulowanie…',
|
||||
cancel_failed: 'Anulowanie nie powiodło się: ',
|
||||
cancel_failed: 'Anulowanie nie powiodło się.',
|
||||
mic_denied: 'Odmowa dostępu do mikrofonu. Sprawdź uprawnienia przeglądarki.',
|
||||
mic_insecure_origin: 'Wprowadzanie głosowe wymaga bezpiecznego połączenia. Otwórz Hermes przez HTTPS lub z localhost, aby użyć mikrofonu.',
|
||||
mic_no_speech: 'Nie wykryto mowy. Spróbuj ponownie.',
|
||||
@@ -22346,6 +22386,7 @@ const LOCALES = {
|
||||
approval_responding: 'Odpowiadanie…',
|
||||
approval_gateway_unsupported: 'Zatwierdzenia wymagają nowszej bramy. Zaktualizuj połączoną bramę Hermes, aby włączyć tę funkcję.',
|
||||
approval_gateway_unsupported_label: 'Zatwierdzenia nie są obsługiwane',
|
||||
approval_pending_count: (n) => `1 z ${n} oczekujących`,
|
||||
clarify_heading: 'Wymagane wyjaśnienie',
|
||||
clarify_hint: 'Wybierz opcję lub wpisz własną odpowiedź poniżej.',
|
||||
clarify_other: 'Inne',
|
||||
@@ -23056,6 +23097,8 @@ const LOCALES = {
|
||||
settings_desc_conversation_outline: 'Pokazuje desktopową listę skrótów do pytań użytkownika w bieżącej konwersacji. Domyślnie wyłączone.',
|
||||
settings_label_hide_suggestions: 'Ukryj sugestie nowej konwersacji',
|
||||
settings_desc_hide_suggestions: 'Ukryj trzy domyślne przyciski sugestii na pustym ekranie nowej konwersacji, aby zapobiec przypadkowym kliknięciom.',
|
||||
settings_label_hide_empty_state_panel: 'Ukryj panel powitalny nowej konwersacji',
|
||||
settings_desc_hide_empty_state_panel: 'Ukryj logo, nagłówek, podtytuł i przyciski sugestii w pustej nowej konwersacji.',
|
||||
settings_label_virtualize_transcript: 'Wirtualizuj długie transkrypcje',
|
||||
settings_desc_virtualize_transcript: "Renderuj tylko widoczną część bardzo długich zapisów czatu (ponad 80 wiadomości) dla płynniejszego przewijania. Wyłącz to, jeśli zauważysz jakiekolwiek problemy z renderowaniem lub przewijaniem, lub chcesz, aby wyszukiwanie w przeglądarce (Ctrl+F) obejmowało całą konwersację \u2014 pełny zapis wyrenderuje się od razu.",
|
||||
settings_label_token_usage: 'Pokaż zużycie tokenów',
|
||||
@@ -23933,7 +23976,7 @@ const LOCALES = {
|
||||
_speech: 'vi-VN',
|
||||
// boot.js
|
||||
cancelling: 'Đang hủy…',
|
||||
cancel_failed: 'Hủy thất bại: ',
|
||||
cancel_failed: 'Hủy thất bại.',
|
||||
mic_denied: 'Quyền truy cập microphone bị từ chối. Kiểm tra quyền trình duyệt.',
|
||||
mic_no_speech: 'Không phát hiện giọng nói. Hãy thử lại.',
|
||||
mic_network: 'Nhận diện giọng nói không khả dụng.',
|
||||
@@ -24631,6 +24674,8 @@ const LOCALES = {
|
||||
settings_label_conversation_outline: 'Hiện mục lục cuộc trò chuyện',
|
||||
settings_desc_conversation_outline: 'Hiển thị danh sách nhảy nhanh cho các câu hỏi của người dùng trong cuộc trò chuyện hiện tại trên desktop. Mặc định tắt.',
|
||||
settings_label_hide_suggestions: 'Ẩn gợi ý chat mới',
|
||||
settings_label_hide_empty_state_panel: 'Ẩn bảng chào mừng cuộc trò chuyện mới',
|
||||
settings_desc_hide_empty_state_panel: 'Ẩn biểu trưng, tiêu đề, phụ đề và các nút gợi ý trong cuộc trò chuyện mới trống.',
|
||||
settings_desc_hide_suggestions: 'Ẩn ba nút gợi ý mặc định trên màn hình chat mới để tránh bấm nhầm.',
|
||||
settings_label_token_usage: 'Hiện mức sử dụng token',
|
||||
settings_label_token_speed: 'Hiện tốc độ token (TPS)',
|
||||
@@ -25436,6 +25481,7 @@ const LOCALES = {
|
||||
|
||||
approval_gateway_unsupported: 'Phê duyệt cần gateway mới hơn. Hãy nâng cấp Hermes gateway đang kết nối để bật tính năng này.',
|
||||
approval_gateway_unsupported_label: 'Không hỗ trợ phê duyệt',
|
||||
approval_pending_count: (n) => `1 trong ${n} đang chờ`,
|
||||
approval_skip_all: '⚡ Bỏ qua tất cả trong phiên này',
|
||||
approval_skip_all_title: 'Bỏ qua mọi yêu cầu phê duyệt cho phiên này',
|
||||
checkpoint_title: 'Checkpoint',
|
||||
|
||||
+8
-1
@@ -496,7 +496,7 @@
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
|
||||
<div class="approval-counter" id="approvalCounter" style="display:none;"></div>
|
||||
<div class="approval-btns" id="approvalBtns">
|
||||
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
|
||||
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
@@ -1176,6 +1176,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_hide_suggestions">Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsHideEmptyStatePanel" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_hide_empty_state_panel">Hide new-chat welcome panel</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_hide_empty_state_panel">Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsVirtualizeTranscript" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
|
||||
+91
-18
@@ -1395,8 +1395,8 @@ async function send(){
|
||||
_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);
|
||||
S.pendingFiles=[];renderTray();
|
||||
if(S.activeStreamId&&typeof cancelStream==='function'){
|
||||
showToast(t('busy_interrupt_confirm'),2000);
|
||||
await cancelStream('busy-interrupt');
|
||||
if(await cancelStream('busy-interrupt')) showToast(t('busy_interrupt_confirm'),2000);
|
||||
else showToast(t('cancel_failed'),null,'error');
|
||||
} else {
|
||||
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
|
||||
}
|
||||
@@ -2649,7 +2649,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_scheduleAnchorRegistryCleanup(600000);
|
||||
// Applying an event and painting it are separate outcomes. Reasoning uses the
|
||||
// optional holder to decide whether a temporary visible fallback is needed.
|
||||
function _applyToAnchor(sourceEventType, rawEventData, sseEvent, renderOutcome){
|
||||
function _applyToAnchor(sourceEventType, rawEventData, sseEvent, renderOutcome, options={}){
|
||||
if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=false;
|
||||
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return null;
|
||||
const raw=(rawEventData&&typeof rawEventData==='object')?rawEventData:{};
|
||||
@@ -2674,7 +2674,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
sourceEvent,
|
||||
{session_id:activeSid,stream_id:streamId}
|
||||
);
|
||||
const rendered=_renderAnchorLiveScene();
|
||||
const rendered=options&&options.render===false?false:_renderAnchorLiveScene();
|
||||
if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=rendered;
|
||||
return result;
|
||||
}catch(err){
|
||||
@@ -3467,7 +3467,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!row||typeof row!=='object') return false;
|
||||
const identity=row.identity&&typeof row.identity==='object'?row.identity:{};
|
||||
const values=[row.row_id,row.local_id,row.event_id,identity.local_id,identity.event_id];
|
||||
return values.some(value=>String(value||'').startsWith('live-'));
|
||||
if(values.some(value=>String(value||'').startsWith('live-'))) return true;
|
||||
// Provider tool-call IDs do not use the live prefix. A stream owner without
|
||||
// a settled assistant index still identifies a projected live row.
|
||||
const group=row.group&&typeof row.group==='object'?row.group:{};
|
||||
const hasStreamOwner=!!(row.stream_id||row.run_id||identity.stream_id||identity.run_id);
|
||||
const hasAssistantMessageIndex=group.assistant_msg_idx!==undefined&&group.assistant_msg_idx!==null;
|
||||
return hasStreamOwner&&!hasAssistantMessageIndex;
|
||||
}
|
||||
function _anchorSceneMessageRowsHaveThinking(messageRows){
|
||||
if(!(messageRows instanceof Map)) return false;
|
||||
@@ -3482,7 +3488,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(String(row.status||'').toLowerCase()!=='running') return row;
|
||||
if(!_anchorSceneRowHasLiveIdentity(row)) return row;
|
||||
if(row.role==='thinking'&&hasSettledThinking) return null;
|
||||
return {...row,status:'completed'};
|
||||
const sealed={...row,status:'completed'};
|
||||
if(row.payload&&typeof row.payload==='object'){
|
||||
sealed.payload={...row.payload,status:'completed'};
|
||||
if(row.role==='tool') sealed.payload.done=true;
|
||||
}
|
||||
if(row.role==='tool'&&row.tool&&typeof row.tool==='object'){
|
||||
sealed.tool={...row.tool,done:true};
|
||||
}
|
||||
return sealed;
|
||||
}
|
||||
function _anchorSceneRowLooksLikeFinalAnswer(rowTextKey, finalKey){
|
||||
if(!rowTextKey||!finalKey) return false;
|
||||
@@ -3694,6 +3708,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function _anchorSceneHasOwnedOutcomes(scene){
|
||||
return !!(
|
||||
(Array.isArray(scene&&scene.artifacts)&&scene.artifacts.length)
|
||||
|| (Array.isArray(scene&&scene.side_effects)&&scene.side_effects.length)
|
||||
);
|
||||
}
|
||||
function _attachProjectedAnchorSceneToLastAssistant(messages){
|
||||
if(!_anchorRegistry||!Array.isArray(messages)) return false;
|
||||
let lastAsst=null;
|
||||
@@ -3709,9 +3729,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(!lastAsst) return false;
|
||||
const projectedScene=_projectLiveAnchorActivityScene();
|
||||
const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);
|
||||
if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length){
|
||||
const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);
|
||||
if(scene&&Array.isArray(scene.activity_rows)&&(scene.activity_rows.length||hasOwnedOutcomes)){
|
||||
const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);
|
||||
const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity';
|
||||
const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes;
|
||||
if(!shouldPersistScene) return false;
|
||||
lastAsst._anchor_stream_id=streamId;
|
||||
lastAsst._anchor_activity_scene=scene;
|
||||
@@ -3753,16 +3774,38 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// falls back to the full renderMd path — identical structure, just not
|
||||
// incremental. (#5455 WS2.1)
|
||||
const _anchorProseSmdCache = new Map();
|
||||
function _anchorProseIncrementalNode(key, text){
|
||||
function _finalizeAnchorProseIncrementalNode(st){
|
||||
if(!st || !st.parser || st.finalized) return;
|
||||
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
|
||||
window.smd.parser_end(st.parser);
|
||||
if(body){
|
||||
if(typeof _smdMediaTailFlush === 'function') _smdMediaTailFlush(st.parser);
|
||||
if(typeof _sanitizeSmdLinks === 'function') _sanitizeSmdLinks(body);
|
||||
if(typeof enhanceMarkdownTables === 'function') enhanceMarkdownTables(body);
|
||||
}
|
||||
if(typeof _smdMediaTailClear === 'function') _smdMediaTailClear(st.parser);
|
||||
if(typeof _smdClearParserIdentity === 'function') _smdClearParserIdentity(body, st.parser);
|
||||
st.finalized = true;
|
||||
}
|
||||
function _anchorProseIncrementalNode(key, text, options){
|
||||
if(!window.smd || !key || typeof _safeSmdRenderer!=='function') return null;
|
||||
const finalize=!!(options&&options.finalize);
|
||||
const value=String(text||'');
|
||||
const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade();
|
||||
let st;
|
||||
try{
|
||||
let st=_anchorProseSmdCache.get(key);
|
||||
st=_anchorProseSmdCache.get(key);
|
||||
// Self-heal desyncs (edit/sanitize made the text no longer a pure append):
|
||||
// rebuild the parser+node from scratch, mirroring the _smdWrite guard.
|
||||
if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null;
|
||||
if(st && st.fade!==fade) st=null;
|
||||
if(st && st.finalized && st.writtenText!==value){
|
||||
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
|
||||
if(typeof _smdMediaTailClear === 'function') _smdMediaTailClear(st.parser);
|
||||
if(typeof _smdClearParserIdentity === 'function') _smdClearParserIdentity(body, st.parser);
|
||||
_anchorProseSmdCache.delete(key);
|
||||
st=null;
|
||||
}
|
||||
if(!st){
|
||||
const node=document.createElement('div');
|
||||
node.className='assistant-segment';
|
||||
@@ -3790,9 +3833,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
window.smd.parser_write(st.parser,delta);
|
||||
st.writtenText=value;
|
||||
}
|
||||
if(finalize){
|
||||
_finalizeAnchorProseIncrementalNode(st);
|
||||
}
|
||||
st.node.dataset.rawText=value;
|
||||
return st.node;
|
||||
}catch(_){
|
||||
if(st){
|
||||
const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body');
|
||||
if(typeof _smdMediaTailClear === 'function') _smdMediaTailClear(st.parser);
|
||||
if(typeof _smdClearParserIdentity === 'function') _smdClearParserIdentity(body, st.parser);
|
||||
}
|
||||
_anchorProseSmdCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
@@ -3992,8 +4043,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function _hydrateAnchorRegistryFromActivityScene(scene){
|
||||
if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return false;
|
||||
if(!scene||scene.version!=='activity_scene_v1'||!Array.isArray(scene.activity_rows)||!scene.activity_rows.length) return false;
|
||||
const sceneIdentity=(scene.identity&&typeof scene.identity==='object')?scene.identity:{};
|
||||
const sceneStreamId=sceneIdentity.stream_id||streamId;
|
||||
const sceneRunId=sceneIdentity.run_id||sceneStreamId;
|
||||
const sceneKey=[
|
||||
scene.identity&&scene.identity.stream_id||streamId||'',
|
||||
sceneRunId||'',
|
||||
sceneStreamId||'',
|
||||
scene.activity_rows.length,
|
||||
scene.activity_rows.map(row=>row&&row.row_id||row&&row.local_id||'').join('|'),
|
||||
].join(':');
|
||||
@@ -4022,22 +4077,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
payload.activitySegmentSeq=payload.activitySegmentSeq||row.group.activity_segment_seq;
|
||||
payload.activityBurstId=payload.activityBurstId||row.group.activity_burst_id;
|
||||
}
|
||||
const rowIdentity=(row.identity&&typeof row.identity==='object')?row.identity:{};
|
||||
const sourceEvent={
|
||||
...payload,
|
||||
source_event_type:sourceType,
|
||||
local_id:row.local_id||row.row_id||`snapshot:${streamId}:${i}`,
|
||||
local_id:row.local_id||row.row_id||`snapshot:${sceneStreamId}:${i}`,
|
||||
event_id:row.event_id||null,
|
||||
seq:row.seq??undefined,
|
||||
status:row.status||undefined,
|
||||
stream_id:row.stream_id||streamId,
|
||||
run_id:row.run_id||streamId,
|
||||
stream_id:row.stream_id||rowIdentity.stream_id||sceneStreamId,
|
||||
run_id:row.run_id||rowIdentity.run_id||sceneRunId,
|
||||
// Carry the row's persisted creation timestamp through hydration so the
|
||||
// worklog event timestamp (#5700/#5739) survives a settled-snapshot rebuild
|
||||
// (payload may not carry created_at even when the row does). (#5739 gate.)
|
||||
created_at:payload.created_at??row.created_at??undefined,
|
||||
};
|
||||
try{
|
||||
_anchorApi.applyAssistantTurnAnchorSourceEvent(_anchorRegistry,sourceEvent,{session_id:activeSid,stream_id:streamId,run_id:streamId});
|
||||
_anchorApi.applyAssistantTurnAnchorSourceEvent(_anchorRegistry,sourceEvent,{session_id:activeSid,stream_id:sceneStreamId,run_id:sceneRunId});
|
||||
}catch(err){
|
||||
if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){
|
||||
_anchorShadowWarned=true;
|
||||
@@ -5563,7 +5619,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
source.addEventListener('approval',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
_applyToAnchor('approval',d,e);
|
||||
showApprovalForSession(activeSid, d, 1);
|
||||
showApprovalForSession(activeSid, d, d.pending_count || 1);
|
||||
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
|
||||
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
|
||||
});
|
||||
@@ -5581,6 +5637,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
|
||||
if((d.session_id||activeSid)!==activeSid) return;
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
_applyToAnchor('state_saved',d,e,null,{render:false});
|
||||
_showPersistentStateToast(d.kind, d.name||'', {created:String(d.action||'').toLowerCase()==='created'});
|
||||
});
|
||||
|
||||
@@ -5928,10 +5985,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// render also populates the cache with the correctly-collapsed DOM, and
|
||||
// the same-frame JS restore absorbs the collapse so there is no jump.
|
||||
// (#5260 gate-cert: keep-open must be transient + uncached for everyone.)
|
||||
// #6385: capture the scroll snapshot from the LIVE DOM before arming
|
||||
// keep-open, so the collapse render below anchors to the content the
|
||||
// reader was actually viewing — not to a stale intermediate state where
|
||||
// the worklog was temporarily expanded.
|
||||
const _doneLiveScrollSnapshot=typeof _captureMessageScrollSnapshot==='function'
|
||||
? _captureMessageScrollSnapshot()
|
||||
: null;
|
||||
if(typeof _armKeepSettledWorklogOpen==='function') _armKeepSettledWorklogOpen(_settledStreamId);
|
||||
syncTopbar();renderMessages({preserveScroll:true});
|
||||
if(typeof _disarmKeepSettledWorklogOpen==='function') _disarmKeepSettledWorklogOpen();
|
||||
if(typeof _renderMessagesWithScrollSnapshot==='function') _renderMessagesWithScrollSnapshot();
|
||||
if(typeof _renderMessagesWithScrollSnapshot==='function') _renderMessagesWithScrollSnapshot({_prescrollSnapshot:_doneLiveScrollSnapshot});
|
||||
else renderMessages({preserveScroll:true});
|
||||
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls);
|
||||
@@ -6477,6 +6541,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return `${m.role}|${ts}|${body.slice(0,160)}`;
|
||||
}
|
||||
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
|
||||
function _isHistoricalAnchorActivityScene(scene){
|
||||
if(!scene||typeof scene!=='object') return false;
|
||||
const identity=scene.identity&&typeof scene.identity==='object'?scene.identity:null;
|
||||
const turnId=identity&&typeof identity.turn_id==='string'?identity.turn_id:'';
|
||||
return turnId.indexOf('historical:')===0;
|
||||
}
|
||||
function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){
|
||||
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
|
||||
if(!prevMessages.length||!nextMessages.length) return nextMessages;
|
||||
@@ -6491,6 +6561,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const k=_messageIdentityKey(nm); if(!k) continue;
|
||||
const pm=prevIdx.get(k); if(!pm) continue;
|
||||
for(const f of _EPHEMERAL_TURN_FIELDS){
|
||||
if(f==='_anchor_activity_scene'&&_isHistoricalAnchorActivityScene(pm[f])) continue;
|
||||
if(pm[f]!=null && nm[f]==null) nm[f]=pm[f];
|
||||
}
|
||||
}
|
||||
@@ -7050,7 +7121,9 @@ function showApprovalCard(pending, pendingCount) {
|
||||
const counter = $("approvalCounter");
|
||||
if (counter) {
|
||||
if (pendingCount && pendingCount > 1) {
|
||||
counter.textContent = "1 of " + pendingCount + " pending";
|
||||
counter.textContent = (typeof t === "function")
|
||||
? t("approval_pending_count", pendingCount)
|
||||
: ("1 of " + pendingCount + " pending");
|
||||
counter.style.display = "";
|
||||
} else {
|
||||
counter.style.display = "none";
|
||||
|
||||
@@ -2526,6 +2526,7 @@ function _kanbanRenderBoard(){
|
||||
board.innerHTML = _kanbanEmptyBoardHtml();
|
||||
return;
|
||||
}
|
||||
board.classList.toggle('kanban-board-consolidated', !_kanbanLanesByProfile);
|
||||
const columns = _kanbanVisibleTasks();
|
||||
const total = columns.reduce((n, col) => n + (col.tasks || []).length, 0);
|
||||
if ($('kanbanSummary')) $('kanbanSummary').textContent = String(t('kanban_visible_tasks')).replace('{0}', total);
|
||||
@@ -8641,6 +8642,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(showConversationOutlineCb) payload.show_conversation_outline=showConversationOutlineCb.checked;
|
||||
const hideSuggestionsCb=$('settingsHideSuggestions');
|
||||
if(hideSuggestionsCb) payload.hide_empty_state_suggestions=hideSuggestionsCb.checked;
|
||||
const hideEmptyStatePanelCb=$('settingsHideEmptyStatePanel');
|
||||
if(hideEmptyStatePanelCb) payload.hide_empty_state_panel=hideEmptyStatePanelCb.checked;
|
||||
const virtualizeTranscriptCb=$('settingsVirtualizeTranscript');
|
||||
if(virtualizeTranscriptCb){
|
||||
payload.virtualize_transcript=virtualizeTranscriptCb.checked;
|
||||
@@ -8794,6 +8797,10 @@ async function _autosavePreferencesSettings(payload){
|
||||
window._hideEmptyStateSuggestions=!!(saved&&saved.hide_empty_state_suggestions);
|
||||
if(typeof applyEmptyStateSuggestionPref==='function') applyEmptyStateSuggestionPref();
|
||||
}
|
||||
if(payload&&payload.hide_empty_state_panel!==undefined){
|
||||
window._hideEmptyStatePanel=!!(saved&&saved.hide_empty_state_panel);
|
||||
if(typeof applyEmptyStatePanelPref==='function') applyEmptyStatePanelPref();
|
||||
}
|
||||
if(payload&&payload.show_conversation_outline!==undefined){
|
||||
window._showConversationOutline=!!(saved&&saved.show_conversation_outline);
|
||||
document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled';
|
||||
@@ -9199,6 +9206,17 @@ async function loadSettingsPanel(){
|
||||
_schedulePreferencesAutosave();
|
||||
},{once:false});
|
||||
}
|
||||
const hideEmptyStatePanelCb=$('settingsHideEmptyStatePanel');
|
||||
if(hideEmptyStatePanelCb){
|
||||
hideEmptyStatePanelCb.checked=settings.hide_empty_state_panel===true;
|
||||
window._hideEmptyStatePanel=hideEmptyStatePanelCb.checked;
|
||||
if(typeof applyEmptyStatePanelPref==='function') applyEmptyStatePanelPref();
|
||||
hideEmptyStatePanelCb.addEventListener('change',()=>{
|
||||
window._hideEmptyStatePanel=hideEmptyStatePanelCb.checked;
|
||||
if(typeof applyEmptyStatePanelPref==='function') applyEmptyStatePanelPref();
|
||||
_schedulePreferencesAutosave();
|
||||
},{once:false});
|
||||
}
|
||||
const virtualizeTranscriptCb=$('settingsVirtualizeTranscript');
|
||||
if(virtualizeTranscriptCb){
|
||||
// #4343: EXPERIMENTAL/opt-IN, default OFF. Honor a stored true only when
|
||||
@@ -9813,6 +9831,13 @@ function _extensionSidecarCard(sidecars){
|
||||
const proxyConsentRequired=proxy.consent_required===true;
|
||||
const proxyOriginChanged=proxy.origin_changed===true;
|
||||
const proxyPath=(proxy&&proxy.path)||'';
|
||||
// token-v1 posture: 'local_unprotected' = WebUI auth is off, so proxy consent
|
||||
// is grantable by any unauthenticated local caller. Warn the operator to
|
||||
// enable authentication before wiring up a sidecar (design §9.1).
|
||||
const proxyUnprotected=proxy.posture==='local_unprotected';
|
||||
const proxyWarning=proxyUnprotected
|
||||
?`<div class="extension-sidecar-warning">⚠ WebUI authentication is off. This sidecar's proxy consent can be granted by any local process. Set a password in Settings → Password before using sidecar extensions.</div>`
|
||||
:'';
|
||||
const proxyStatus=proxyConsented
|
||||
?'consented'
|
||||
:(proxyOriginChanged
|
||||
@@ -9837,6 +9862,7 @@ function _extensionSidecarCard(sidecars){
|
||||
<div><span>Proxy path</span><code>${esc(proxyPath)}</code></div>
|
||||
</div>
|
||||
<div class="extension-sidecar-actions">${proxyButton}</div>
|
||||
${proxyWarning}
|
||||
<div class="extension-sidecar-runtime" data-sidecar-runtime-index="${index}" hidden></div>
|
||||
</div>`;
|
||||
}).join('')}</div>`:'<div class="extension-url-empty">No loopback sidecars declared.</div>';
|
||||
|
||||
+3
-2
@@ -2315,6 +2315,7 @@ async function loadSession(sid){
|
||||
setComposerStatus('');
|
||||
updateQueueBadge(sid);
|
||||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||||
startApprovalPolling(sid);
|
||||
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
|
||||
// Workspace refresh is guarded by session id inside loadDir(); keep it
|
||||
// after the transcript's first paint so chat switching is not competing
|
||||
@@ -4905,8 +4906,8 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
ICONS.stop,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
await cancelSessionStream(session);
|
||||
showToast(t('stream_stopped'));
|
||||
if(await cancelSessionStream(session)) showToast(t('stream_stopped'));
|
||||
else showToast(t('cancel_failed'),null,'error');
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
+16
-5
@@ -1992,6 +1992,7 @@
|
||||
.approval-card.collapsed .approval-desc,.approval-card.collapsed .approval-cmd,.approval-card.collapsed .approval-counter,.approval-card.collapsed .approval-btns{display:none;}
|
||||
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
|
||||
.approval-counter{font-size:11px;color:var(--muted);margin:0 0 12px;font-variant-numeric:tabular-nums;letter-spacing:0.01em;}
|
||||
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
|
||||
.approval-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;white-space:nowrap;}
|
||||
.approval-btn:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2);}
|
||||
@@ -2513,6 +2514,7 @@
|
||||
@keyframes pulse{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:.8;transform:scale(1)}}
|
||||
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px;color:var(--muted);}
|
||||
.empty-state.no-suggestions .suggestion-grid{display:none}
|
||||
.empty-state.no-welcome{display:none}
|
||||
/* Empty-state brand mark: a free-breathing winged caduceus with a soft
|
||||
"spirit" bloom behind it — no app-icon tile. Glow + mark gradient are
|
||||
tuned separately for dark vs light themes (see --logo-glow / .hm-g* below). */
|
||||
@@ -4410,6 +4412,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.transparent-turn-footer .lf-time{color:var(--text);}
|
||||
.transparent-turn-footer .lf-sep{opacity:.4;}
|
||||
.transparent-turn-footer .lf-status{color:var(--muted);opacity:.85;}
|
||||
.transparent-turn-footer .lf-model{color:var(--muted);opacity:.85;}
|
||||
.transparent-turn-footer .lf-ttft{color:var(--accent);opacity:.9;}
|
||||
.transparent-turn-footer .lf-tokens{font-variant-numeric:tabular-nums;}
|
||||
|
||||
@@ -5452,6 +5455,7 @@ main.main > #mainPlugin{display:none;}
|
||||
.extension-sidecar-row-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;}
|
||||
.extension-sidecar-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;}
|
||||
.extension-sidecar-meta{margin-top:2px;font-size:11px;color:var(--muted);overflow-wrap:anywhere;}
|
||||
.extension-sidecar-warning{margin-top:8px;padding:7px 9px;border:1px solid var(--warning,#d29922);border-radius:6px;background:var(--warning-bg,rgba(210,153,34,0.12));color:var(--warning,#d29922);font-size:11px;line-height:1.4;overflow-wrap:anywhere;}
|
||||
.extension-sidecar-fields{display:grid;grid-template-columns:1fr;gap:5px;margin-top:8px;}
|
||||
.extension-sidecar-fields>div{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:12px;color:var(--muted);}
|
||||
.extension-sidecar-fields span{flex:0 0 82px;}
|
||||
@@ -6108,6 +6112,7 @@ main.main > #mainPlugin{display:none;}
|
||||
.msg-usage-inline,
|
||||
.msg-duration-inline,
|
||||
.msg-gateway-inline,
|
||||
.msg-used-model-inline,
|
||||
.gateway-failover-inline,
|
||||
.msg-model-warning-inline {
|
||||
font-size: 11px;
|
||||
@@ -6431,9 +6436,11 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.workspace-artifact-empty{padding:16px 8px;color:var(--muted);font-size:12px;line-height:1.5;text-align:center;}
|
||||
.workspace-artifact-item{display:block;width:100%;text-align:left;background:var(--surface-subtle);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:6px;cursor:pointer;}
|
||||
.workspace-artifact-item:hover{border-color:var(--accent);}
|
||||
.workspace-artifact-name{font-family:'SF Mono',ui-monospace,monospace;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);}
|
||||
.workspace-artifact-dir{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--muted);margin-top:1px;}
|
||||
.workspace-artifact-meta{margin-top:3px;color:var(--muted);font-size:11px;}
|
||||
.workspace-artifact-filename{font-family:'SF Mono',ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere;}
|
||||
.workspace-artifact-directory{display:flex;min-width:0;overflow:hidden;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;font-size:11px;color:var(--muted);}
|
||||
.workspace-artifact-directory-head{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-directory-tail{flex:0 0 auto;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-meta{display:inline-block;margin-top:4px;padding:1px 5px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;line-height:1.3;}
|
||||
.workspace-artifacts-count{opacity:.65;font-size:11px;}
|
||||
.rightpanel[data-active-tab="artifacts"] .breadcrumb-bar,.rightpanel[data-active-tab="artifacts"] .file-tree,.rightpanel[data-active-tab="artifacts"] #wsEmptyState{display:none!important;}
|
||||
.rightpanel[data-active-tab="artifacts"] .workspace-artifacts{display:block;}
|
||||
@@ -6695,10 +6702,12 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-list-title{font-size:13px;font-weight:600;line-height:1.35;}
|
||||
.kanban-board-wrap{flex:1;min-height:0;overflow:auto;padding:16px;background:var(--bg);}
|
||||
.kanban-board{display:flex;gap:12px;min-height:100%;overflow-x:auto;padding-bottom:8px;}
|
||||
.kanban-board.kanban-board-consolidated{height:100%;}
|
||||
.kanban-column{display:flex;flex-direction:column;min-width:260px;max-width:320px;flex:1;background:var(--panel);border:1px solid var(--border);border-radius:10px;min-height:240px;}
|
||||
.kanban-column-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text);}
|
||||
.kanban-count{font-size:11px;color:var(--muted);background:var(--input-bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;}
|
||||
.kanban-column-body{display:flex;flex-direction:column;gap:8px;padding:10px;min-height:0;max-height:min(68vh,720px);overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;}
|
||||
.kanban-board-consolidated .kanban-column-body{height:100%;max-height:unset;overscroll-behavior:auto;}
|
||||
.kanban-card{border:1px solid var(--border);border-radius:9px;background:var(--bg);padding:10px;cursor:pointer;box-shadow:var(--shadow-sm);}
|
||||
.kanban-card:hover,.kanban-card.selected{border-color:var(--accent);}
|
||||
.kanban-card-title{font-size:13px;font-weight:650;color:var(--text);line-height:1.35;margin-bottom:6px;}
|
||||
@@ -7020,10 +7029,12 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
min-width:min(280px, calc(100vw - 24px));
|
||||
max-width:calc(100vw - 24px);
|
||||
}
|
||||
/* Modal scales to viewport width on phones with reasonable padding. */
|
||||
.kanban-modal-overlay{padding:12px;}
|
||||
/* Modal scales to viewport width on phones with reasonable padding.
|
||||
Ensure it's on screen and scrollable */
|
||||
.kanban-modal-overlay{align-items:safe center;overflow-y:auto;padding:12px;}
|
||||
.kanban-modal{padding:16px 16px 14px;border-radius:14px;}
|
||||
.kanban-modal-row-inline{flex-direction:column;gap:0;}
|
||||
.kanban-column-body{overscroll-behavior:auto;}
|
||||
}
|
||||
|
||||
/* ── Logs panel (#1455) ───────────────────────────────────────────────────── */
|
||||
|
||||
+317
-21
@@ -7030,6 +7030,17 @@ function _formatGatewayModelLabel(modelId,labelText,routing){
|
||||
const via=_gatewayRoutingLabel(routing);
|
||||
return via?`${base} ${via}`:base;
|
||||
}
|
||||
function _usedModelTurnChipLabel(msg){
|
||||
if(!msg)return'';
|
||||
// Gateway turns own their model label via _formatGatewayModelLabel (which
|
||||
// falls back to msg._usedModel when routing omits used_model), so suppress
|
||||
// the additive chip whenever routing metadata is present — not only when
|
||||
// routing.used_model is set — to guarantee one model label per turn.
|
||||
if(msg._gatewayRouting)return'';
|
||||
const usedModel=String(msg._usedModel||'').trim();
|
||||
if(!usedModel)return'';
|
||||
return _compactComposerModelChipLabel(usedModel,getModelLabel(usedModel));
|
||||
}
|
||||
function _gatewayRoutingFailoverText(routing){
|
||||
if(!routing||!routing.has_failover)return'';
|
||||
const attempts=Array.isArray(routing.routing)?routing.routing:[];
|
||||
@@ -7901,7 +7912,7 @@ async function handleComposerPrimaryAction(){
|
||||
const action=typeof getComposerPrimaryAction==='function'?getComposerPrimaryAction():'send';
|
||||
if(action==='disabled') return;
|
||||
if(action==='stop'){
|
||||
if(typeof cancelStream==='function') await cancelStream('composer-stop');
|
||||
if(typeof cancelStream==='function' && !await cancelStream('composer-stop')) showToast(t('cancel_failed'),null,'error');
|
||||
return;
|
||||
}
|
||||
await send();
|
||||
@@ -11750,13 +11761,40 @@ function _applyTransparentRowFading(turn){
|
||||
row.setAttribute('data-transparent-fade',String(step));
|
||||
}
|
||||
}
|
||||
// Resolve the assistant message that carries a transparent turn's settled
|
||||
// metadata (duration / used model / TTFT / usage). A tool-using turn renders
|
||||
// multiple assistant segments — earlier activity segment(s) then the final
|
||||
// answer — and the metadata is stamped only on the LAST assistant message
|
||||
// (see api/streaming.py finalization). `turn.querySelector('.assistant-segment')`
|
||||
// returns the FIRST segment, which has no metadata, so the footer (model chip
|
||||
// included) would render empty exactly on tool turns (#6068 gate round 2).
|
||||
// Scan segments last→first for the final metadata-bearing assistant message,
|
||||
// falling back to the last assistant segment when none carries metadata yet.
|
||||
function _transparentTurnMetaMessage(turn){
|
||||
if(!turn)return null;
|
||||
const segs=turn.querySelectorAll('.assistant-segment[data-msg-idx]');
|
||||
let fallback=null;
|
||||
for(let si=segs.length-1;si>=0;si--){
|
||||
const mi=segs[si].getAttribute('data-msg-idx');
|
||||
if(mi==null)continue;
|
||||
const candidate=S.messages[Number(mi)];
|
||||
if(!candidate||candidate.role!=='assistant')continue;
|
||||
if(!fallback)fallback=candidate;
|
||||
if(candidate._turnDuration!=null||candidate._usedModel||candidate._firstTokenMs!=null||candidate._turnUsage)return candidate;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
// ── Transparent turn footer (elapsed · tokens · TTFT · status) ───────────
|
||||
// Mirrors the live run-status line for settled turns in transparent
|
||||
// mode. Shows duration, first-token time, token usage, and final status.
|
||||
// Only rendered for turns that have transparent event rows.
|
||||
function _transparentTurnFooterHtml(durationText, ttftText, tokensText, statusText){
|
||||
function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText, modelTitle){
|
||||
const parts=[];
|
||||
if(durationText) parts.push(`<span class="lf-time">${esc(durationText)}</span>`);
|
||||
if(modelText){
|
||||
const titleAttr=(modelTitle&&modelTitle!==modelText)?` title="${esc(modelTitle)}"`:'';
|
||||
parts.push(`<span class="lf-model"${titleAttr}>${esc(modelText)}</span>`);
|
||||
}
|
||||
if(ttftText) parts.push(`<span class="lf-ttft" title="${esc(t('first_token_time')||'Time to first token')}">TTFT ${esc(ttftText)}</span>`);
|
||||
if(tokensText) parts.push(`<span class="lf-tokens">${esc(tokensText)}</span>`);
|
||||
if(statusText) parts.push(`<span class="lf-status">${esc(statusText)}</span>`);
|
||||
@@ -11775,10 +11813,12 @@ function _renderTransparentTurnFooter(turn, opts){
|
||||
return;
|
||||
}
|
||||
const durationText=opts&&opts.durationText||'';
|
||||
const modelText=opts&&opts.modelText||'';
|
||||
const modelTitle=opts&&opts.modelTitle||'';
|
||||
const ttftText=opts&&opts.ttftText||'';
|
||||
const tokensText=opts&&opts.tokensText||'';
|
||||
const statusText=opts&&opts.statusText||(t('done')||'Done');
|
||||
const html=_transparentTurnFooterHtml(durationText, ttftText, tokensText, statusText);
|
||||
const html=_transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText, modelTitle);
|
||||
let footer=turn.querySelector('.transparent-turn-footer');
|
||||
if(!html){
|
||||
if(footer) footer.remove();
|
||||
@@ -12309,6 +12349,7 @@ function _anchorSceneToolCallFromRow(row, opts){
|
||||
)||'',
|
||||
done:settled?true:(tool.done!==null&&tool.done!==undefined?tool.done:(row.status!=='running'&&row.status!=='pending')),
|
||||
is_error:!!(tool.is_error||payload.is_error||row.status==='error'||row.status==='failed'),
|
||||
is_diff:!!(tool.is_diff||payload.is_diff||payload.isDiff),
|
||||
duration:tool.duration||payload.duration||payload.duration_seconds,
|
||||
started_at:firstValidTimestampSeconds(tool.started_at, payload.started_at, rowTs),
|
||||
created_at:firstValidTimestampSeconds(tool.created_at, payload.created_at, rowTs),
|
||||
@@ -12351,7 +12392,9 @@ function _anchorSceneNodeForRow(row, opts){
|
||||
// renderMd path below, which stays the source of truth for the final DOM.
|
||||
const proseKey=row.local_id||row.row_id||'';
|
||||
if(!settled && proseKey && typeof window.__anchorProseIncrementalNode==='function'){
|
||||
const inc=window.__anchorProseIncrementalNode(proseKey,text);
|
||||
const inc=window.__anchorProseIncrementalNode(proseKey,text,{
|
||||
finalize:String(row.status||'').toLowerCase()==='completed',
|
||||
});
|
||||
// Route the incremental node through the shared row-decoration block below
|
||||
// (data-anchor-scene-row / -row-id / -row-role / -source-event-type) instead
|
||||
// of returning early — otherwise live incremental prose rows lose the
|
||||
@@ -14706,6 +14749,226 @@ function _toolArgsSnapshot(args, limit){
|
||||
return out;
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalMessageText(message){
|
||||
if(!message||typeof message!=='object') return '';
|
||||
const content=message.content;
|
||||
if(typeof content==='string') return content;
|
||||
if(!Array.isArray(content)) return '';
|
||||
return content.filter(part=>part&&typeof part==='object'&&part.type==='text').map(part=>{
|
||||
if(!part||typeof part!=='object') return '';
|
||||
return String(part.text||part.content||'');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalMessageHasVisibleText(message){
|
||||
return _idLinkedHistoricalMessageText(message).trim()!=='';
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalMessageRef(message, rawIdx){
|
||||
if(message&&typeof message==='object'){
|
||||
for(const key of ['message_id','id','local_id']){
|
||||
const value=message[key];
|
||||
if(typeof value==='string'&&value.trim()) return value.trim();
|
||||
if(typeof value==='number'&&Number.isFinite(value)) return String(value);
|
||||
}
|
||||
}
|
||||
return `raw_idx:${rawIdx}`;
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalToolArguments(toolCall){
|
||||
if(!toolCall||typeof toolCall!=='object') return null;
|
||||
const fn=toolCall.function;
|
||||
if(!fn||typeof fn!=='object'||Array.isArray(fn)) return null;
|
||||
const raw=fn.arguments;
|
||||
if(raw===undefined||raw===null||raw==='') return null;
|
||||
if(raw&&typeof raw==='object'&&!Array.isArray(raw)) return raw;
|
||||
if(typeof raw!=='string') return null;
|
||||
try{
|
||||
const parsed=JSON.parse(raw);
|
||||
return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:null;
|
||||
}catch(e){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalToolResultRaw(message){
|
||||
if(!message||typeof message!=='object') return null;
|
||||
const content=message.content;
|
||||
return typeof content==='string'?content:null;
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalRedactSnippet(value){
|
||||
let text=String(value||'');
|
||||
if(!text) return '';
|
||||
if(typeof _redactToolTargetLabel==='function'){
|
||||
try{text=_redactToolTargetLabel(text);}
|
||||
catch(e){}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function _idLinkedHistoricalHasVisibleSidecar(message){
|
||||
if(!message||typeof message!=='object') return false;
|
||||
const visibleKeys=['attachments','_attachments','_statusCard','status_card','statusCard','card','cards','artifact','artifacts','files','images','media'];
|
||||
for(const key of visibleKeys){
|
||||
if(!Object.prototype.hasOwnProperty.call(message,key)) continue;
|
||||
const value=message[key];
|
||||
if(value===undefined||value===null||value===false) continue;
|
||||
if(Array.isArray(value)&&value.length===0) continue;
|
||||
if(typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===0) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Claim legacy settled ownership only when the transcript itself proves a
|
||||
// complete, user-bounded declaration/result/final-answer chain.
|
||||
function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){
|
||||
const list=Array.isArray(messages)?messages:[];
|
||||
const start=Math.max(0,Number(turnStart)||0);
|
||||
const end=Math.min(list.length,Math.max(start,Number(turnEnd)||0));
|
||||
const opts=options&&typeof options==='object'?options:{};
|
||||
const sessionId=String(opts.sessionId||opts.session_id||'').trim();
|
||||
const api=(typeof window!=='undefined')?window.HermesAssistantTurnAnchors:null;
|
||||
if(!sessionId||!api||typeof api.projectAssistantTurnAnchorHistoricalTranscriptScene!=='function') return null;
|
||||
|
||||
const declarations=[];
|
||||
const declarationIds=new Set();
|
||||
const declarationRefs=[];
|
||||
const visibleAssistantIndexes=[];
|
||||
const assistantIndexes=[];
|
||||
const resultsById=new Map();
|
||||
for(let rawIdx=start;rawIdx<end;rawIdx++){
|
||||
const message=list[rawIdx];
|
||||
if(!message||typeof message!=='object') continue;
|
||||
const role=message.role;
|
||||
if(role==='user'&&rawIdx===start) continue;
|
||||
if(message._anchor_activity_scene) return null;
|
||||
if(role==='assistant'){
|
||||
assistantIndexes.push(rawIdx);
|
||||
const hasVisibleText=_idLinkedHistoricalMessageHasVisibleText(message);
|
||||
const reasoningText=_assistantReasoningPayloadText(message);
|
||||
if(hasVisibleText) visibleAssistantIndexes.push(rawIdx);
|
||||
if(reasoningText) return null;
|
||||
if(_idLinkedHistoricalHasVisibleSidecar(message)) return null;
|
||||
if(Array.isArray(message._partial_tool_calls)&&message._partial_tool_calls.length) return null;
|
||||
if(Array.isArray(message.content)&&message.content.some(part=>part&&typeof part==='object'&&part.type==='tool_use')) return null;
|
||||
const toolCalls=Array.isArray(message.tool_calls)?message.tool_calls:[];
|
||||
if(toolCalls.length&&hasVisibleText) return null;
|
||||
if(!toolCalls.length){
|
||||
if(hasVisibleText) continue;
|
||||
return null;
|
||||
}
|
||||
if(message.content!==undefined&&message.content!==null&&message.content!=='') return null;
|
||||
const messageRef=_idLinkedHistoricalMessageRef(message,rawIdx);
|
||||
if(!declarationRefs.includes(messageRef)) declarationRefs.push(messageRef);
|
||||
for(const toolCall of toolCalls){
|
||||
const callId=String(toolCall&&toolCall.id||'').trim();
|
||||
const fn=toolCall&&toolCall.function;
|
||||
const name=String(fn&&fn.name||'').trim();
|
||||
const args=_idLinkedHistoricalToolArguments(toolCall);
|
||||
if(!callId||!name||args===null||declarationIds.has(callId)) return null;
|
||||
declarationIds.add(callId);
|
||||
declarations.push({callId,name,args,rawIdx,messageRef});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(role!=='tool') return null;
|
||||
const callId=String(message.tool_call_id||'').trim();
|
||||
if(!callId||!declarationIds.has(callId)) return null;
|
||||
const matches=resultsById.get(callId)||[];
|
||||
matches.push({message,rawIdx});
|
||||
resultsById.set(callId,matches);
|
||||
}
|
||||
|
||||
if(!declarations.length||visibleAssistantIndexes.length!==1) return null;
|
||||
const ownerIndex=visibleAssistantIndexes[0];
|
||||
if(ownerIndex!==assistantIndexes[assistantIndexes.length-1]) return null;
|
||||
const owner=list[ownerIndex];
|
||||
if(Array.isArray(owner.tool_calls)&&owner.tool_calls.length) return null;
|
||||
const ownerRef=_idLinkedHistoricalMessageRef(owner,ownerIndex);
|
||||
for(const declaration of declarations){
|
||||
const matches=resultsById.get(declaration.callId)||[];
|
||||
if(matches.length!==1||matches[0].rawIdx<=declaration.rawIdx||matches[0].rawIdx>=ownerIndex) return null;
|
||||
}
|
||||
if(resultsById.size!==declarations.length) return null;
|
||||
|
||||
const sourceRefs=declarationRefs.concat(ownerRef).filter((value,index,array)=>array.indexOf(value)===index);
|
||||
const turnId=['historical',sessionId,declarationRefs[0],ownerRef].join(':');
|
||||
const activityEvents=[];
|
||||
for(let index=0;index<declarations.length;index++){
|
||||
const declaration=declarations[index];
|
||||
const resultEntry=resultsById.get(declaration.callId)[0];
|
||||
const args=_toolArgsSnapshot(declaration.args);
|
||||
const resultRaw=_idLinkedHistoricalToolResultRaw(resultEntry.message);
|
||||
if(resultRaw===null) return null;
|
||||
const resultSnippet=_idLinkedHistoricalRedactSnippet(_cliToolResultSnippet(resultRaw));
|
||||
const patchSnippet=_cliPatchSnippetFromArgs(declaration.name,args);
|
||||
const isDiff=_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet);
|
||||
const snippet=_idLinkedHistoricalRedactSnippet(_cliToolCardSnippet(resultSnippet,patchSnippet));
|
||||
const status=String(resultEntry.message.status||'').trim().toLowerCase();
|
||||
const isError=resultEntry.message.is_error===true||status==='error'||status==='failed'||status==='failure';
|
||||
activityEvents.push({
|
||||
source_type:'tool_complete',
|
||||
seq:index+1,
|
||||
local_id:`historical:${declaration.messageRef}:tool:${declaration.callId}`,
|
||||
payload:{
|
||||
id:declaration.callId,
|
||||
tid:declaration.callId,
|
||||
tool_call_id:declaration.callId,
|
||||
name:declaration.name,
|
||||
args,
|
||||
command:String(args.command||args.cmd||''),
|
||||
snippet,
|
||||
done:true,
|
||||
is_error:isError,
|
||||
is_diff:isDiff,
|
||||
assistant_msg_idx:declaration.rawIdx,
|
||||
},
|
||||
});
|
||||
}
|
||||
let scene;
|
||||
try{
|
||||
scene=api.projectAssistantTurnAnchorHistoricalTranscriptScene({
|
||||
session_id:sessionId,
|
||||
turn_id:turnId,
|
||||
local_id:ownerRef,
|
||||
source_message_refs:sourceRefs,
|
||||
activity_events:activityEvents,
|
||||
settled_message:{role:'assistant',id:ownerRef,content:_idLinkedHistoricalMessageText(owner)},
|
||||
},{mode:opts.mode||'compact_worklog'});
|
||||
}catch(e){
|
||||
return null;
|
||||
}
|
||||
if(!scene||scene.version!=='activity_scene_v1'||scene.activity_rows.length!==declarations.length) return null;
|
||||
return {ownerIndex,scene};
|
||||
}
|
||||
|
||||
function _hydrateIdLinkedHistoricalToolScenes(messages, options){
|
||||
const list=Array.isArray(messages)?messages:[];
|
||||
let turnStart=-1;
|
||||
let hydrated=0;
|
||||
const hydrateTurn=(turnEnd)=>{
|
||||
if(turnStart<0||turnEnd<=turnStart+1) return;
|
||||
let hydratedTurn;
|
||||
try{hydratedTurn=_idLinkedHistoricalTurnScene(list,turnStart,turnEnd,options);}
|
||||
catch(e){return;}
|
||||
if(!hydratedTurn) return;
|
||||
const owner=list[hydratedTurn.ownerIndex];
|
||||
try{owner._anchor_activity_scene=hydratedTurn.scene;}
|
||||
catch(e){return;}
|
||||
if(owner._anchor_activity_scene===hydratedTurn.scene) hydrated+=1;
|
||||
};
|
||||
for(let rawIdx=0;rawIdx<list.length;rawIdx++){
|
||||
const message=list[rawIdx];
|
||||
if(!message||message.role!=='user') continue;
|
||||
hydrateTurn(rawIdx);
|
||||
turnStart=rawIdx;
|
||||
}
|
||||
hydrateTurn(list.length);
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
function _captureMessageScrollSnapshot(){
|
||||
const el=$('messages');
|
||||
if(!el) return null;
|
||||
@@ -15086,7 +15349,13 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){
|
||||
}
|
||||
}
|
||||
function _renderMessagesWithScrollSnapshot(options){
|
||||
const scrollSnapshot=_captureMessageScrollSnapshot();
|
||||
// Accept an optional pre-captured scroll snapshot via _prescrollSnapshot.
|
||||
// When provided, it is used INSTEAD of capturing a fresh one from the current
|
||||
// DOM state — essential for the STREAM_DONE collapse render: the caller has
|
||||
// already captured the snapshot from the LIVE DOM (before keep-open was armed),
|
||||
// and re-capturing from the intermediate expanded-worklog state would capture
|
||||
// stale anchors that no longer exist after the worklog collapses. (#6385)
|
||||
const scrollSnapshot=(options&&options._prescrollSnapshot)||_captureMessageScrollSnapshot();
|
||||
renderMessages({...(options||{}),preserveScroll:true});
|
||||
_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);
|
||||
}
|
||||
@@ -15349,6 +15618,10 @@ function renderMessages(options){
|
||||
const scrollSnapshot=(preserveScroll||_messageUserUnpinned)?_captureMessageScrollSnapshot():null;
|
||||
const inner=$('msgInner');
|
||||
const sid=S.session?S.session.session_id:null;
|
||||
if(!S.busy&&Array.isArray(S.messages)&&typeof _hydrateIdLinkedHistoricalToolScenes==='function'){
|
||||
const activityMode=typeof chatActivityMode==='function'?chatActivityMode():'compact_worklog';
|
||||
_hydrateIdLinkedHistoricalToolScenes(S.messages,{sessionId:sid,mode:activityMode});
|
||||
}
|
||||
const msgCount=S.messages.length;
|
||||
// During session switch, S.messages is intentionally cleared while the full
|
||||
// message fetch is still in flight. Other async updates can still call
|
||||
@@ -16586,7 +16859,7 @@ function renderMessages(options){
|
||||
const msg=S.messages[mi]||{};
|
||||
if(msg.role!=='assistant') continue;
|
||||
const routing=msg._gatewayRouting||null;
|
||||
const gatewayText=_formatGatewayModelLabel(S.session&&S.session.model||'', '', routing);
|
||||
const gatewayText=_formatGatewayModelLabel(String(msg._usedModel||'').trim()||(S.session&&S.session.model)||'', '', routing);
|
||||
const failoverText=_gatewayRoutingFailoverText(routing);
|
||||
const modelWarningText=_gatewayModelWarningText(routing);
|
||||
const hasTurnUsage=!!msg._turnUsage;
|
||||
@@ -16595,12 +16868,13 @@ function renderMessages(options){
|
||||
// Worklog above the final answer.
|
||||
const compactWorklogForMessage=isCompactWorklogMode()&&(toolCallAssistantIdxs.has(mi)||assistantThinking.has(mi));
|
||||
const durationText=compactWorklogForMessage?'':_formatTurnDuration(msg._turnDuration);
|
||||
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue;
|
||||
const usedModelText=_usedModelTurnChipLabel(msg);
|
||||
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText&&!usedModelText) continue;
|
||||
const seg=assistantSegments.get(mi);
|
||||
const row=seg?seg.closest('.assistant-turn'):null;
|
||||
const footerRows=row?row.querySelectorAll('.msg-foot'):[];
|
||||
const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null;
|
||||
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline')) continue;
|
||||
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline,.msg-used-model-inline')) continue;
|
||||
const fragments=[];
|
||||
if(modelWarningText){
|
||||
const warning=document.createElement('span');
|
||||
@@ -16626,6 +16900,23 @@ function renderMessages(options){
|
||||
duration.textContent=`Done in ${durationText}`;
|
||||
fragments.push(duration);
|
||||
}
|
||||
// The transparent turn footer owns the model label (.lf-model) whenever
|
||||
// the turn has transparent event rows — skip the generic chip there so
|
||||
// exactly one model label renders per turn. Model sits after duration to
|
||||
// match the transparent footer order (elapsed · model · …).
|
||||
const _transparentFooterOwnsModel=usedModelText&&isTransparentStream()&&row&&(()=>{
|
||||
const blocks=_assistantTurnBlocks(row);
|
||||
return !!(blocks&&blocks.querySelector(':scope > .transparent-event-row'));
|
||||
})();
|
||||
if(usedModelText&&!_transparentFooterOwnsModel){
|
||||
const usedModel=document.createElement('span');
|
||||
usedModel.className='msg-used-model-inline';
|
||||
usedModel.textContent=usedModelText;
|
||||
// Preserve the full (uncompacted) model id on hover where available.
|
||||
const usedModelFull=String(msg._usedModel||'').trim();
|
||||
if(usedModelFull&&usedModelFull!==usedModelText) usedModel.title=usedModelFull;
|
||||
fragments.push(usedModel);
|
||||
}
|
||||
if(window._showTokenUsage&&hasTurnUsage){
|
||||
const usage=document.createElement('span');
|
||||
usage.className='msg-usage-inline';
|
||||
@@ -16673,26 +16964,31 @@ function renderMessages(options){
|
||||
}
|
||||
_applyTransparentRowFading(turn);
|
||||
if(hasTransparentRows){
|
||||
// Find the corresponding message to read duration/usage.
|
||||
const seg=turn.querySelector('.assistant-segment');
|
||||
// Read turn metadata from the final metadata-bearing assistant segment,
|
||||
// not querySelector's first match — a tool turn's activity segment
|
||||
// precedes the answer, and the metadata lives on the last message
|
||||
// (#6068 gate round 2: multi-segment turns lost the model label).
|
||||
const msg=_transparentTurnMetaMessage(turn);
|
||||
let durationText='';
|
||||
let modelText='';
|
||||
let modelTitle='';
|
||||
let ttftText='';
|
||||
let tokensText='';
|
||||
if(seg){
|
||||
const mi=seg.getAttribute('data-msg-idx');
|
||||
if(mi!=null){
|
||||
const msg=S.messages[Number(mi)]||{};
|
||||
if(msg._turnDuration!=null) durationText=_formatTurnDuration(msg._turnDuration);
|
||||
if(msg._firstTokenMs!=null) ttftText=_formatFirstToken(msg._firstTokenMs);
|
||||
if(msg._turnUsage){
|
||||
const inTok=msg._turnUsage.input_tokens||0;
|
||||
const outTok=msg._turnUsage.output_tokens||0;
|
||||
tokensText=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
}
|
||||
if(msg){
|
||||
if(msg._turnDuration!=null) durationText=_formatTurnDuration(msg._turnDuration);
|
||||
modelText=_usedModelTurnChipLabel(msg);
|
||||
if(modelText) modelTitle=String(msg._usedModel||'').trim();
|
||||
if(msg._firstTokenMs!=null) ttftText=_formatFirstToken(msg._firstTokenMs);
|
||||
if(msg._turnUsage){
|
||||
const inTok=msg._turnUsage.input_tokens||0;
|
||||
const outTok=msg._turnUsage.output_tokens||0;
|
||||
tokensText=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
}
|
||||
}
|
||||
_renderTransparentTurnFooter(turn,{
|
||||
durationText,
|
||||
modelText,
|
||||
modelTitle,
|
||||
ttftText,
|
||||
tokensText,
|
||||
statusText: t('done')||'Done',
|
||||
|
||||
+19
-9
@@ -586,16 +586,26 @@ function renderSessionArtifacts(){
|
||||
if(normWs && p.startsWith(normWs)) return p.slice(normWs.length);
|
||||
return p;
|
||||
};
|
||||
const splitArtifactDisplayPath = (path) => {
|
||||
const slash = path.lastIndexOf('/');
|
||||
if(slash < 0) return {name: path, head: '', tail: ''};
|
||||
const directory = path.slice(0, slash + 1);
|
||||
const parentSlash = directory.lastIndexOf('/', directory.length - 2);
|
||||
return {
|
||||
name: path.slice(slash + 1),
|
||||
head: directory.slice(0, parentSlash + 1),
|
||||
tail: directory.slice(parentSlash + 1),
|
||||
};
|
||||
};
|
||||
root.innerHTML = items.map(item => {
|
||||
const dPath = displayPath(item.path);
|
||||
const idx = dPath.lastIndexOf('/');
|
||||
const fileName = idx >= 0 ? dPath.slice(idx + 1) : dPath;
|
||||
const dirPath = idx >= 0 ? dPath.slice(0, idx) : '';
|
||||
return `<button type="button" class="workspace-artifact-item" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)">
|
||||
<div class="workspace-artifact-name">${esc(fileName)}</div>
|
||||
${dirPath ? `<div class="workspace-artifact-dir">${esc(dirPath)}</div>` : ''}
|
||||
<div class="workspace-artifact-meta">${esc(item.source || 'session')}</div>
|
||||
</button>`;
|
||||
const path = displayPath(item.path);
|
||||
const parts = splitArtifactDisplayPath(path);
|
||||
const directory = (parts.head || parts.tail)
|
||||
? `<div class="workspace-artifact-directory"><span class="workspace-artifact-directory-head">${esc(parts.head)}</span><span class="workspace-artifact-directory-tail">${esc(parts.tail)}</span></div>`
|
||||
: '';
|
||||
const source = item.source ? esc(item.source) : esc(t('workspace_artifact_source_session') || 'session');
|
||||
const sourceAttrs = item.source ? '' : ' data-i18n="workspace_artifact_source_session"';
|
||||
return `<button type="button" class="workspace-artifact-item" title="${esc(path)}" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)"><div class="workspace-artifact-filename">${esc(parts.name)}</div>${directory}<div class="workspace-artifact-meta"${sourceAttrs}>${source}</div></button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Public browser gate for the normal conversation lifecycle.
|
||||
"""Public browser gate for the normal + terminal-error lifecycle.
|
||||
|
||||
This test boots the real WebUI server with isolated state, drives the real chat
|
||||
composer in Chromium, and supplies deterministic runtime events through the
|
||||
@@ -28,9 +28,12 @@ from urllib.parse import urlsplit
|
||||
PROMPT = "Exercise the public conversation lifecycle gate."
|
||||
REASONING_TEXT = "Checking the persistent assistant turn."
|
||||
FINAL_TEXT = "Lifecycle gate final answer."
|
||||
FINAL_ACK_TEXT = "Lifecycle"
|
||||
FINAL_PREFIX = "Lifecycle gate "
|
||||
FINAL_SUFFIX = "final answer."
|
||||
FINAL_ACK_TEXT = "Lifecycle"
|
||||
TERMINAL_PROCESS_TEXT = "Lifecycle terminal process check"
|
||||
TERMINAL_ERROR_TEXT = "Lifecycle gate encountered a terminal-side error."
|
||||
SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal"
|
||||
TOOL_NAME = "read_file"
|
||||
TOOL_ID = "lifecycle-tool-1"
|
||||
TEST_BITE = os.environ.get("LIFECYCLE_TEST_BITE", "").strip()
|
||||
@@ -39,6 +42,52 @@ ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000
|
||||
|
||||
|
||||
def _latest_anchor_scene_from_disk(state_root: Path, session_id: str) -> dict | None:
|
||||
session_file_candidates = [
|
||||
state_root / "webui-state" / "sessions" / f"{session_id}.json",
|
||||
state_root / "sessions" / f"{session_id}.json",
|
||||
]
|
||||
for session_file in session_file_candidates:
|
||||
if not session_file.exists():
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(session_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
records = raw.get("anchor_activity_scenes")
|
||||
if not isinstance(records, dict):
|
||||
continue
|
||||
candidates = []
|
||||
for record in records.values():
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
scene = record.get("scene")
|
||||
if not isinstance(scene, dict):
|
||||
continue
|
||||
idx = record.get("message_index")
|
||||
try:
|
||||
message_index = int(idx)
|
||||
except (TypeError, ValueError):
|
||||
message_index = -1
|
||||
candidates.append((message_index, scene))
|
||||
if not candidates:
|
||||
continue
|
||||
_, latest = max(candidates, key=lambda item: item[0])
|
||||
return latest
|
||||
return None
|
||||
|
||||
|
||||
def _safe_request_post_data(request_or_route_request) -> str:
|
||||
raw = getattr(request_or_route_request, "post_data", None)
|
||||
if callable(raw):
|
||||
raw = raw()
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
@@ -238,7 +287,8 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
class DeterministicGateway:
|
||||
"""A localhost-only Gateway Runs server with test-controlled phase gates."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, scenario: str) -> None:
|
||||
self.scenario = scenario
|
||||
self.activity_ready = threading.Event()
|
||||
self.release_settle = threading.Event()
|
||||
self.final_prefix_ready = threading.Event()
|
||||
@@ -302,6 +352,11 @@ class DeterministicGateway:
|
||||
"event": "reasoning.available",
|
||||
"text": REASONING_TEXT,
|
||||
})
|
||||
if owner.scenario == "terminal-error":
|
||||
self._event("message.delta", {
|
||||
"event": "message.delta",
|
||||
"delta": TERMINAL_PROCESS_TEXT,
|
||||
})
|
||||
self._event("tool.started", {
|
||||
"event": "tool.started",
|
||||
"tool": TOOL_NAME,
|
||||
@@ -319,21 +374,30 @@ class DeterministicGateway:
|
||||
owner.activity_ready.set()
|
||||
if not owner.release_settle.wait(timeout=30):
|
||||
return
|
||||
self._event("message.delta", {
|
||||
"event": "message.delta",
|
||||
"delta": FINAL_PREFIX,
|
||||
})
|
||||
owner.final_prefix_ready.set()
|
||||
if not owner.release_terminal.wait(timeout=30):
|
||||
return
|
||||
self._event("message.delta", {
|
||||
"event": "message.delta",
|
||||
"delta": FINAL_SUFFIX,
|
||||
})
|
||||
self._event("run.completed", {
|
||||
"event": "run.completed",
|
||||
"usage": {"input_tokens": 12, "output_tokens": 5},
|
||||
})
|
||||
if owner.scenario == "terminal-error":
|
||||
owner.final_prefix_ready.set()
|
||||
if not owner.release_terminal.wait(timeout=30):
|
||||
return
|
||||
self._event("run.failed", {
|
||||
"event": "run.failed",
|
||||
"error": TERMINAL_ERROR_TEXT,
|
||||
})
|
||||
else:
|
||||
self._event("message.delta", {
|
||||
"event": "message.delta",
|
||||
"delta": FINAL_PREFIX,
|
||||
})
|
||||
owner.final_prefix_ready.set()
|
||||
if not owner.release_terminal.wait(timeout=30):
|
||||
return
|
||||
self._event("message.delta", {
|
||||
"event": "message.delta",
|
||||
"delta": FINAL_SUFFIX,
|
||||
})
|
||||
self._event("run.completed", {
|
||||
"event": "run.completed",
|
||||
"usage": {"input_tokens": 12, "output_tokens": 5},
|
||||
})
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
@@ -417,6 +481,9 @@ def _capture_anchor_scene_requests(page):
|
||||
def _activity_snapshot(page) -> dict:
|
||||
return page.evaluate(
|
||||
"""() => {
|
||||
const messages = (typeof S !== 'undefined' && Array.isArray(S.messages)) ? S.messages : [];
|
||||
const assistants = messages.filter((message) => message && message.role === 'assistant');
|
||||
const lastAssistant = assistants.length ? assistants[assistants.length - 1] : null;
|
||||
const turn = document.querySelector('#liveAssistantTurn') ||
|
||||
Array.from(document.querySelectorAll('.assistant-turn')).pop() || null;
|
||||
const groups = turn ? Array.from(turn.querySelectorAll('[data-anchor-scene-owner="1"]')) : [];
|
||||
@@ -424,7 +491,9 @@ def _activity_snapshot(page) -> dict:
|
||||
const visibleFinal = turn ? Array.from(turn.querySelectorAll('.assistant-segment .msg-body'))
|
||||
.filter(el => {
|
||||
const segment = el.closest('.assistant-segment');
|
||||
const role = segment && segment.getAttribute('data-anchor-row-role');
|
||||
return segment && !segment.hidden &&
|
||||
role !== 'prose' &&
|
||||
!segment.classList.contains('assistant-segment-worklog-source') &&
|
||||
getComputedStyle(segment).display !== 'none';
|
||||
})
|
||||
@@ -450,11 +519,20 @@ def _activity_snapshot(page) -> dict:
|
||||
rows: rows.map(row => ({
|
||||
role: row.getAttribute('data-anchor-row-role'),
|
||||
source: row.getAttribute('data-anchor-source-event-type'),
|
||||
status: row.getAttribute('data-anchor-row-status'),
|
||||
tool: row.getAttribute('data-tool-name'),
|
||||
text: row.innerText.trim(),
|
||||
classes: row.className,
|
||||
})),
|
||||
visibleFinal,
|
||||
assistantMessage: lastAssistant ? {
|
||||
turnDuration: lastAssistant._turnDuration,
|
||||
hasError: Boolean(lastAssistant._error),
|
||||
anchorTerminalState: lastAssistant._anchor_activity_scene
|
||||
&& (lastAssistant._anchor_activity_scene.terminal_state
|
||||
|| (lastAssistant._anchor_activity_scene.lifecycle
|
||||
&& lastAssistant._anchor_activity_scene.lifecycle.terminal_state)) || null,
|
||||
} : null,
|
||||
transcript: (document.querySelector('#msgInner') || {}).innerText || '',
|
||||
};
|
||||
}"""
|
||||
@@ -485,31 +563,121 @@ def _expand_settled_worklog(page) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _terminal_rows(snapshot: dict) -> list[dict]:
|
||||
return [row for row in snapshot["rows"] if row["role"] == "terminal"]
|
||||
|
||||
|
||||
def _process_rows(snapshot: dict) -> list[dict]:
|
||||
return [
|
||||
row for row in snapshot["rows"]
|
||||
if row["role"] == "prose" and _is_terminal_process_row_text(row.get("text") or "")
|
||||
]
|
||||
|
||||
|
||||
def _is_terminal_process_row_text(text: str) -> bool:
|
||||
text = " ".join(text.split())
|
||||
if not text:
|
||||
return False
|
||||
return (
|
||||
TERMINAL_PROCESS_TEXT.startswith(text)
|
||||
or text.startswith(TERMINAL_PROCESS_TEXT)
|
||||
)
|
||||
|
||||
|
||||
def _tool_rows(snapshot: dict) -> list[dict]:
|
||||
return [row for row in snapshot["rows"] if row["role"] == "tool"]
|
||||
|
||||
|
||||
def _is_terminal_row_error(row: dict) -> bool:
|
||||
status = (row.get("status") or "").strip()
|
||||
if status in {"error", "failed"}:
|
||||
return True
|
||||
if status and status not in {"", "done"}:
|
||||
return False
|
||||
row_text = (row.get("text") or "").lower()
|
||||
row_classes = (row.get("classes") or "")
|
||||
return (
|
||||
"error" in row_text
|
||||
or "warning" in row_classes
|
||||
or "agent-activity-status-error" in row_classes
|
||||
)
|
||||
|
||||
|
||||
def _assert_no_running_tool_rows(rows: list[dict]) -> None:
|
||||
running = [
|
||||
row
|
||||
for row in rows
|
||||
if (
|
||||
row.get("status") == "running"
|
||||
or ("tool-card-running" in row.get("classes", ""))
|
||||
)
|
||||
]
|
||||
assert not running, rows
|
||||
|
||||
|
||||
def _assert_live_activity(snapshot: dict) -> None:
|
||||
assert snapshot["live"], snapshot
|
||||
assert snapshot["groupCount"] == 1, snapshot
|
||||
roles = [row["role"] for row in snapshot["rows"]]
|
||||
assert roles.count("thinking") == 1, snapshot
|
||||
assert roles.count("tool") == 1, snapshot
|
||||
tool_rows = [row for row in snapshot["rows"] if row["role"] == "tool"]
|
||||
tool_rows = _tool_rows(snapshot)
|
||||
assert len(tool_rows) == 1 and tool_rows[0]["tool"] == TOOL_NAME, snapshot
|
||||
assert "tool-card-running" not in tool_rows[0]["classes"], snapshot
|
||||
_assert_no_running_tool_rows(tool_rows)
|
||||
assert any(REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot
|
||||
assert all(FINAL_TEXT not in text for text in snapshot["visibleFinal"]), snapshot
|
||||
assert any(character.isdigit() for character in snapshot["summary"][0]["label"]), snapshot
|
||||
|
||||
|
||||
def _assert_settled(snapshot: dict) -> None:
|
||||
def _assert_settled(snapshot: dict, scenario: str) -> None:
|
||||
assert not snapshot["live"], snapshot
|
||||
assert snapshot["groupCount"] == 1, snapshot
|
||||
roles = [row["role"] for row in snapshot["rows"]]
|
||||
assert "thinking" in roles and "tool" in roles, snapshot
|
||||
tool_rows = [row for row in snapshot["rows"] if row["role"] == "tool"]
|
||||
tool_rows = _tool_rows(snapshot)
|
||||
assert len(tool_rows) == 1 and tool_rows[0]["tool"] == TOOL_NAME, snapshot
|
||||
assert "tool-card-running" not in tool_rows[0]["classes"], snapshot
|
||||
_assert_no_running_tool_rows(tool_rows)
|
||||
assert any(REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot
|
||||
assert sum(FINAL_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot
|
||||
assert snapshot["transcript"].count(FINAL_TEXT) == 1, snapshot
|
||||
if scenario == "terminal-error":
|
||||
assert "terminal" in roles, snapshot
|
||||
terminal_rows = _terminal_rows(snapshot)
|
||||
assert terminal_rows, snapshot
|
||||
assert all(_is_terminal_row_error(row) for row in terminal_rows), snapshot
|
||||
assert snapshot["assistantMessage"] is not None, snapshot
|
||||
turn_duration = snapshot["assistantMessage"].get("turnDuration")
|
||||
assert isinstance(turn_duration, (int, float)) and turn_duration > 0, snapshot
|
||||
assert snapshot["assistantMessage"]["hasError"] is True, snapshot
|
||||
assert snapshot["assistantMessage"]["anchorTerminalState"] in {"error", "failed"}, snapshot
|
||||
assert all(FINAL_TEXT not in text for text in snapshot["visibleFinal"]), snapshot
|
||||
assert sum(TERMINAL_ERROR_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot
|
||||
assert TERMINAL_ERROR_TEXT in snapshot["transcript"], snapshot
|
||||
else:
|
||||
assert sum(FINAL_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot
|
||||
assert snapshot["transcript"].count(FINAL_TEXT) == 1, snapshot
|
||||
|
||||
|
||||
def _parse_json_payload(raw: str | bytes | None) -> dict:
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
return payload
|
||||
|
||||
|
||||
def _assert_process_row_present(snapshot: dict) -> list[dict]:
|
||||
rows = _process_rows(snapshot)
|
||||
assert len(rows) == 1, snapshot
|
||||
assert all(_is_terminal_process_row_text(row["text"]) for row in rows), snapshot
|
||||
assert all(TERMINAL_PROCESS_TEXT not in text for text in snapshot["visibleFinal"]), snapshot
|
||||
return rows
|
||||
|
||||
|
||||
def _semantic_activity(snapshot: dict) -> list[dict]:
|
||||
@@ -542,7 +710,24 @@ def main() -> int:
|
||||
tempfile.mkdtemp(prefix="hermes-lifecycle-artifacts-")
|
||||
)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
gateway = DeterministicGateway()
|
||||
scenario = SCENARIO
|
||||
if scenario not in {"normal", "terminal-error"}:
|
||||
raise ValueError(
|
||||
f"Unsupported LIFECYCLE_SCENARIO {scenario!r}; "
|
||||
"expected 'normal' or 'terminal-error'"
|
||||
)
|
||||
if TEST_BITE not in {"", "drop-anchor-persistence", "drop-terminal-anchor-row"}:
|
||||
raise ValueError(
|
||||
f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; "
|
||||
"expected one of '', 'drop-anchor-persistence', 'drop-terminal-anchor-row'"
|
||||
)
|
||||
if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error":
|
||||
raise ValueError(
|
||||
"drop-terminal-anchor-row is only valid for "
|
||||
"LIFECYCLE_SCENARIO=terminal-error"
|
||||
)
|
||||
|
||||
gateway = DeterministicGateway(scenario)
|
||||
gateway.start()
|
||||
|
||||
agent_dir = state_dir / "no-agent"
|
||||
@@ -598,15 +783,43 @@ def main() -> int:
|
||||
context = browser.new_context(base_url=base_url)
|
||||
page = context.new_page()
|
||||
anchor_scene_requests = _capture_anchor_scene_requests(page)
|
||||
if TEST_BITE == "drop-anchor-persistence":
|
||||
page.route(
|
||||
"**/api/session/anchor-scene",
|
||||
lambda route: route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"ok":true}',
|
||||
),
|
||||
)
|
||||
if TEST_BITE:
|
||||
def _route_anchor_scene(route):
|
||||
if TEST_BITE == "drop-anchor-persistence":
|
||||
route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body='{"ok":true}',
|
||||
)
|
||||
return
|
||||
if TEST_BITE == "drop-terminal-anchor-row":
|
||||
raw_payload = _safe_request_post_data(route.request)
|
||||
payload = _parse_json_payload(raw_payload)
|
||||
if isinstance(payload, dict):
|
||||
scene = payload.get("scene")
|
||||
if isinstance(scene, dict):
|
||||
rows = scene.get("activity_rows")
|
||||
if isinstance(rows, list):
|
||||
kept = [
|
||||
row for row in rows
|
||||
if not (isinstance(row, dict) and row.get("role") == "terminal")
|
||||
]
|
||||
if len(kept) != len(rows):
|
||||
mutated = dict(payload)
|
||||
updated_scene = dict(scene)
|
||||
updated_scene["activity_rows"] = kept
|
||||
mutated["scene"] = updated_scene
|
||||
response = route.fetch(post_data=json.dumps(mutated))
|
||||
route.fulfill(response=response)
|
||||
return
|
||||
response = route.fetch()
|
||||
route.fulfill(response=response)
|
||||
return
|
||||
response = route.fetch()
|
||||
route.fulfill(response=response)
|
||||
return
|
||||
|
||||
page.route("**/api/session/anchor-scene", _route_anchor_scene)
|
||||
errors = _capture_page_errors(page)
|
||||
page.goto("/", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("#msg", state="visible", timeout=15000)
|
||||
@@ -631,54 +844,153 @@ def main() -> int:
|
||||
)
|
||||
live_snapshot = _activity_snapshot(page)
|
||||
_assert_live_activity(live_snapshot)
|
||||
print("OK live activity: one Anchor worklog with reasoning + completed tool")
|
||||
if scenario == "terminal-error":
|
||||
_assert_process_row_present(live_snapshot)
|
||||
print("OK live activity: terminal-error run keeps reasoning + completed tool")
|
||||
else:
|
||||
print("OK live activity: one Anchor worklog with reasoning + completed tool")
|
||||
_wait_for_live_anchor_projection(page)
|
||||
|
||||
gateway.release_settle.set()
|
||||
if not gateway.final_prefix_ready.wait(timeout=10):
|
||||
raise AssertionError("mock Gateway did not emit the final-answer prefix")
|
||||
page.wait_for_function(
|
||||
"""text => {
|
||||
const turn = document.querySelector('#liveAssistantTurn');
|
||||
return turn && (turn.innerText || '').includes(text);
|
||||
}""",
|
||||
arg=FINAL_ACK_TEXT,
|
||||
timeout=10000,
|
||||
)
|
||||
gateway.release_terminal.set()
|
||||
page.wait_for_function(
|
||||
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
|
||||
!document.querySelector('#liveAssistantTurn') &&
|
||||
(document.querySelector('#msgInner') || {}).innerText?.includes(text)""",
|
||||
arg=FINAL_TEXT,
|
||||
timeout=15000,
|
||||
)
|
||||
if scenario == "normal":
|
||||
page.wait_for_function(
|
||||
"""text => {
|
||||
const turn = document.querySelector('#liveAssistantTurn');
|
||||
return Boolean(turn) && turn.innerText.includes(text);
|
||||
}""",
|
||||
arg=FINAL_ACK_TEXT,
|
||||
timeout=10000,
|
||||
)
|
||||
gateway.release_terminal.set()
|
||||
page.wait_for_function(
|
||||
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
|
||||
((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""",
|
||||
arg=FINAL_TEXT,
|
||||
timeout=15000,
|
||||
)
|
||||
else:
|
||||
gateway.release_terminal.set()
|
||||
page.wait_for_function(
|
||||
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
|
||||
!document.querySelector('#liveAssistantTurn') &&
|
||||
((document.querySelector('#msgInner') || {}).innerText||'').includes(text)""",
|
||||
arg=TERMINAL_ERROR_TEXT,
|
||||
timeout=15000,
|
||||
)
|
||||
session_id = page.evaluate("S.session && S.session.session_id")
|
||||
assert session_id, "active session id missing after settlement"
|
||||
if not TEST_BITE:
|
||||
if TEST_BITE == "drop-terminal-anchor-row":
|
||||
scene = _wait_for_persisted_scene(
|
||||
base_url,
|
||||
session_id,
|
||||
anchor_scene_requests=anchor_scene_requests,
|
||||
)
|
||||
assert scene.get("version") == "activity_scene_v1", scene
|
||||
scene_rows = scene.get("activity_rows") or []
|
||||
scene_roles = [
|
||||
row.get("role") for row in scene_rows
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "prose"
|
||||
for row in scene_rows
|
||||
), scene
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "thinking"
|
||||
for row in scene_rows
|
||||
), scene
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "tool"
|
||||
for row in scene_rows
|
||||
), scene
|
||||
assert all(
|
||||
not (
|
||||
isinstance(row, dict)
|
||||
and row.get("role") == "terminal"
|
||||
)
|
||||
for row in scene_rows
|
||||
), scene
|
||||
print(
|
||||
"OK persisted scene via API: roles=%s terminal_present=%s"
|
||||
% (sorted(set(scene_roles)), any(role == "terminal" for role in scene_roles))
|
||||
)
|
||||
persisted_scene = _latest_anchor_scene_from_disk(state_dir, session_id)
|
||||
assert persisted_scene is not None, {
|
||||
"session_id": session_id,
|
||||
"state_dir": str(state_dir / "webui-state"),
|
||||
}
|
||||
persisted_rows = persisted_scene.get("activity_rows") or []
|
||||
persisted_roles = [
|
||||
row.get("role") for row in persisted_rows
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "thinking"
|
||||
for row in persisted_rows
|
||||
), {
|
||||
"persisted_rows": persisted_rows,
|
||||
}
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "prose"
|
||||
for row in persisted_rows
|
||||
), {
|
||||
"persisted_rows": persisted_rows,
|
||||
}
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "tool"
|
||||
for row in persisted_rows
|
||||
), {
|
||||
"persisted_rows": persisted_rows,
|
||||
}
|
||||
assert all(
|
||||
not (
|
||||
isinstance(row, dict)
|
||||
and row.get("role") == "terminal"
|
||||
)
|
||||
for row in persisted_rows
|
||||
), {
|
||||
"persisted_rows": persisted_rows,
|
||||
}
|
||||
print(
|
||||
"OK persisted scene on disk: roles=%s terminal_present=%s"
|
||||
% (sorted(set(persisted_roles)), "terminal" in persisted_roles)
|
||||
)
|
||||
elif not TEST_BITE:
|
||||
scene = _wait_for_persisted_scene(
|
||||
base_url,
|
||||
session_id,
|
||||
anchor_scene_requests=anchor_scene_requests,
|
||||
)
|
||||
assert scene.get("version") == "activity_scene_v1", scene
|
||||
if scenario == "terminal-error":
|
||||
scene_rows = scene.get("activity_rows") or []
|
||||
assert any(
|
||||
isinstance(row, dict) and row.get("role") == "terminal" for row in scene_rows
|
||||
), scene
|
||||
_expand_settled_worklog(page)
|
||||
page.wait_for_selector(
|
||||
'.assistant-turn [data-anchor-settled-scene-owner="1"] [data-anchor-scene-row="1"]',
|
||||
timeout=10000,
|
||||
)
|
||||
settled_snapshot = _activity_snapshot(page)
|
||||
_assert_settled(settled_snapshot)
|
||||
_assert_settled(settled_snapshot, scenario)
|
||||
if scenario == "terminal-error":
|
||||
_assert_process_row_present(settled_snapshot)
|
||||
assert _semantic_activity(settled_snapshot) == _semantic_activity(live_snapshot), {
|
||||
"live": _semantic_activity(live_snapshot),
|
||||
"settled": _semantic_activity(settled_snapshot),
|
||||
}
|
||||
print("OK settled: final prose and the same semantic activity coexist without duplication")
|
||||
if scenario == "terminal-error":
|
||||
print("OK settled: terminal row and same activity survived terminal settlement")
|
||||
else:
|
||||
print("OK settled: final prose and the same semantic activity coexist without duplication")
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(
|
||||
"text => (document.querySelector('#msgInner') || {}).innerText?.includes(text)",
|
||||
arg=FINAL_TEXT,
|
||||
arg=TERMINAL_ERROR_TEXT if scenario == "terminal-error" else FINAL_TEXT,
|
||||
timeout=15000,
|
||||
)
|
||||
_expand_settled_worklog(page)
|
||||
@@ -687,11 +999,34 @@ def main() -> int:
|
||||
timeout=2000 if TEST_BITE else 10000,
|
||||
)
|
||||
reloaded_snapshot = _activity_snapshot(page)
|
||||
_assert_settled(reloaded_snapshot)
|
||||
_assert_settled(reloaded_snapshot, scenario)
|
||||
if scenario == "terminal-error":
|
||||
_assert_process_row_present(reloaded_snapshot)
|
||||
assert _semantic_activity(reloaded_snapshot) == _semantic_activity(settled_snapshot), {
|
||||
"settled": _semantic_activity(settled_snapshot),
|
||||
"reloaded": _semantic_activity(reloaded_snapshot),
|
||||
}
|
||||
if scenario == "terminal-error":
|
||||
settled_terminal = _terminal_rows(settled_snapshot)
|
||||
reloaded_terminal = _terminal_rows(reloaded_snapshot)
|
||||
settled_process = _process_rows(settled_snapshot)
|
||||
reloaded_process = _process_rows(reloaded_snapshot)
|
||||
assert len(settled_process) == len(reloaded_process) == 1, {
|
||||
"settled_process": settled_process,
|
||||
"reloaded_process": reloaded_process,
|
||||
}
|
||||
assert settled_process[0]["text"] == reloaded_process[0]["text"], {
|
||||
"settled_process": settled_process,
|
||||
"reloaded_process": reloaded_process,
|
||||
}
|
||||
assert len(settled_terminal) == len(reloaded_terminal) == 1, {
|
||||
"settled_terminal": settled_terminal,
|
||||
"reloaded_terminal": reloaded_terminal,
|
||||
}
|
||||
assert settled_terminal[0]["text"] == reloaded_terminal[0]["text"], {
|
||||
"settled_terminal": settled_terminal[0],
|
||||
"reloaded_terminal": reloaded_terminal[0],
|
||||
}
|
||||
print("OK hard reload: transcript-backed Anchor scene preserves settled parity")
|
||||
|
||||
assert gateway.request_body and gateway.request_body.get("input") == PROMPT, gateway.request_body
|
||||
@@ -710,7 +1045,7 @@ def main() -> int:
|
||||
page.screenshot(path=str(artifact_dir / "failure.png"), full_page=True)
|
||||
(artifact_dir / "snapshot.json").write_text(
|
||||
json.dumps({
|
||||
"scenario": "normal-live-to-final",
|
||||
"scenario": scenario,
|
||||
"test_bite": TEST_BITE or None,
|
||||
"browser_errors": errors,
|
||||
"anchor_scene_requests": anchor_scene_requests,
|
||||
|
||||
@@ -181,6 +181,20 @@ os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
|
||||
# tests that read/write config.yaml stay inside the isolated test home.
|
||||
os.environ['HERMES_CONFIG_PATH'] = str(TEST_STATE_DIR / 'config.yaml')
|
||||
|
||||
# Model-selection env overrides must NOT leak from the runner into tests.
|
||||
# get_effective_default_model() (api/config.py) treats HERMES_MODEL / OPENAI_MODEL
|
||||
# / LLM_MODEL as the highest-priority default-model source — above the isolated
|
||||
# test config. When the pytest process runs inside a live Hermes agent session,
|
||||
# HERMES_MODEL is exported to the agent's runtime model (e.g. "opus-4.8"), which
|
||||
# then overrides the sandbox config and pollutes the model picker/catalog. That
|
||||
# broke test_sprint12 (default-model readback), test_issue1538 (Nous @nous:
|
||||
# prefix invariant) and test_issue1567 (picker capacity/symmetry) whenever the
|
||||
# suite ran on a box with HERMES_MODEL set. Strip them at module level so the
|
||||
# isolated config is authoritative for the pytest process; the out-of-process
|
||||
# test server env is scrubbed identically in the test_server fixture below.
|
||||
for _model_env in ('HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL'):
|
||||
os.environ.pop(_model_env, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_hermes_config_path():
|
||||
@@ -987,6 +1001,13 @@ def test_server():
|
||||
for _k in list(env):
|
||||
if any(_k.startswith(p) for p in _CRED_ENV_PREFIXES):
|
||||
del env[_k]
|
||||
# Model-selection overrides must not reach the test server either. They are
|
||||
# already popped from the pytest process env at module level, but strip them
|
||||
# explicitly here so the subprocess default model comes only from the isolated
|
||||
# config / HERMES_WEBUI_DEFAULT_MODEL below — never a runner-exported
|
||||
# HERMES_MODEL (get_effective_default_model gives these env vars top priority).
|
||||
for _model_env in ('HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL'):
|
||||
env.pop(_model_env, None)
|
||||
# Belt-and-suspenders: keep IMDS disabled in the spawn env too (we set it
|
||||
# at module level above for the pytest process, but make it explicit here
|
||||
# so it's never accidentally cleared by an env.update later).
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
"""Regression tests for atomic config.yaml / profile config.yaml writes.
|
||||
|
||||
Pre-fix behaviour: ``_save_yaml_config_file`` (api.config) and the profile
|
||||
model-config writers (api.profiles) persisted YAML via a plain
|
||||
``Path.write_text``. A crash — or any exception — after ``open(..., "w")``
|
||||
truncated the target file but before the full payload was flushed left the
|
||||
live ``config.yaml`` truncated / corrupt, so the next agent or WebUI start
|
||||
would fail to parse it (an availability regression, not a disclosure one).
|
||||
|
||||
Fix: a shared ``api.paths._atomic_write_text`` helper writes to a temp file in
|
||||
the same directory, ``fsync``s, then ``os.replace``s it into place. Because
|
||||
``os.replace`` is atomic, a failure at any point before the rename commits
|
||||
leaves the ORIGINAL file byte-for-byte intact, and a success swaps in the new
|
||||
contents in a single step.
|
||||
|
||||
These tests pin the helper and all four config.yaml callers across success,
|
||||
fault, metadata, symlink, permission, and concurrency paths so a future
|
||||
refactor cannot silently reintroduce the truncating plain-write.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.paths import _atomic_write_text
|
||||
|
||||
|
||||
def test_atomic_write_replaces_contents(tmp_path: Path) -> None:
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("model:\n default: old\n", encoding="utf-8")
|
||||
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
# No temp files left lying around after a clean write.
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
def test_atomic_write_creates_new_file(tmp_path: Path) -> None:
|
||||
target = tmp_path / "config.yaml"
|
||||
assert not target.exists()
|
||||
|
||||
_atomic_write_text(target, "created: true\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "created: true\n"
|
||||
|
||||
|
||||
def test_atomic_write_preserves_existing_permissions(tmp_path: Path) -> None:
|
||||
"""Rewriting a 0644/0664 config.yaml must not tighten it to 0600.
|
||||
|
||||
``tempfile.mkstemp`` hard-codes 0600 and ``os.replace`` carries the temp
|
||||
file's mode onto the target, so without an explicit chmod every save would
|
||||
silently strip group/other read from a world-readable ``config.yaml`` (the
|
||||
live homelab install ships 0644, profiles 0664). config.yaml holds no
|
||||
secrets, so that tightening is a real regression, not a hardening.
|
||||
"""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("model:\n default: old\n", encoding="utf-8")
|
||||
os.chmod(target, 0o644)
|
||||
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
assert (os.stat(target).st_mode & 0o777) == 0o644
|
||||
|
||||
# A 0664 (group-writable profile config) survives its mode too.
|
||||
os.chmod(target, 0o664)
|
||||
_atomic_write_text(target, "model:\n default: newer\n")
|
||||
assert (os.stat(target).st_mode & 0o777) == 0o664
|
||||
|
||||
# Preserve special permission bits too; replacing the inode must not
|
||||
# silently discard an administrator's setgid policy on a shared config.
|
||||
os.chmod(target, 0o2664)
|
||||
_atomic_write_text(target, "model:\n default: newest\n")
|
||||
assert stat.S_IMODE(os.stat(target).st_mode) == 0o2664
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not all(hasattr(os, name) for name in ("getxattr", "listxattr", "setxattr")),
|
||||
reason="extended attributes are unavailable on this platform",
|
||||
)
|
||||
def test_atomic_write_preserves_existing_user_xattr(tmp_path: Path) -> None:
|
||||
"""Replacing config contents must not discard administrator metadata."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
attribute = "user.hermes_review"
|
||||
value = b"preserve-me"
|
||||
try:
|
||||
os.setxattr(target, attribute, value)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP}:
|
||||
pytest.skip("test filesystem does not support user extended attributes")
|
||||
raise
|
||||
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert attribute in os.listxattr(target)
|
||||
assert os.getxattr(target, attribute) == value
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "listxattr"), reason="xattr probe unavailable")
|
||||
def test_unsupported_xattr_probe_keeps_atomic_replace(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Filesystems without xattr support retain the normal atomic path."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
replace_calls = 0
|
||||
real_replace = os.replace
|
||||
|
||||
def _unsupported_xattrs(_path) -> list[str]:
|
||||
raise OSError(errno.ENOTSUP, "xattrs unsupported")
|
||||
|
||||
def _recording_replace(src, dst) -> None:
|
||||
nonlocal replace_calls
|
||||
replace_calls += 1
|
||||
real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(os, "listxattr", _unsupported_xattrs)
|
||||
monkeypatch.setattr(os, "replace", _recording_replace)
|
||||
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new: true\n"
|
||||
assert replace_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "listxattr"), reason="xattr probe unavailable")
|
||||
def test_xattr_probe_failure_does_not_silently_drop_metadata(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Unexpected metadata-read failures abort before touching the old file."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
|
||||
def _denied_xattrs(_path) -> list[str]:
|
||||
raise PermissionError("xattr access denied")
|
||||
|
||||
monkeypatch.setattr(os, "listxattr", _denied_xattrs)
|
||||
|
||||
with pytest.raises(PermissionError, match="xattr access denied"):
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "old: true\n"
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not shutil.which("getfacl") or not shutil.which("setfacl"),
|
||||
reason="getfacl/setfacl are unavailable",
|
||||
)
|
||||
def test_atomic_write_preserves_existing_posix_acl(tmp_path: Path) -> None:
|
||||
"""POSIX access ACLs, commonly stored as xattrs, survive a rewrite."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
subprocess.run(
|
||||
["setfacl", "-m", "u:12345:r--", str(target)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
expected_acl = subprocess.run(
|
||||
["getfacl", "-cp", str(target)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
actual_acl = subprocess.run(
|
||||
["getfacl", "-cp", str(target)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
assert actual_acl == expected_acl
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "chown"), reason="POSIX ownership semantics")
|
||||
def test_atomic_write_preserves_existing_group(tmp_path: Path) -> None:
|
||||
"""Replacing the inode must not silently reset an existing config's group."""
|
||||
supplementary_groups = [gid for gid in os.getgroups() if gid != os.getegid()]
|
||||
if not supplementary_groups:
|
||||
pytest.skip("requires a supplementary group distinct from the effective gid")
|
||||
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("model:\n default: old\n", encoding="utf-8")
|
||||
expected_gid = supplementary_groups[0]
|
||||
os.chown(target, -1, expected_gid)
|
||||
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
|
||||
assert os.stat(target).st_gid == expected_gid
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "fchown"), reason="POSIX ownership semantics")
|
||||
@pytest.mark.parametrize(
|
||||
"failure",
|
||||
[
|
||||
PermissionError("simulated ownership denial"),
|
||||
OSError(errno.EINVAL, "simulated unsupported ownership transfer"),
|
||||
],
|
||||
)
|
||||
def test_fchown_denial_falls_back_without_temp_debris(
|
||||
tmp_path: Path, monkeypatch, failure: OSError
|
||||
) -> None:
|
||||
"""A writer unable to transfer ownership must keep the old inode contract."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
|
||||
def _deny_fchown(*_args) -> None:
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(os, "fchown", _deny_fchown)
|
||||
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new: true\n"
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
def test_atomic_write_without_posix_fd_metadata_helpers(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Windows-like platforms still use same-directory atomic replacement."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
replace_calls: list[tuple[Path, Path]] = []
|
||||
real_replace = os.replace
|
||||
|
||||
monkeypatch.delattr(os, "fchown", raising=False)
|
||||
monkeypatch.delattr(os, "fchmod", raising=False)
|
||||
|
||||
def _recording_replace(src, dst) -> None:
|
||||
replace_calls.append((Path(src), Path(dst)))
|
||||
real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(os, "replace", _recording_replace)
|
||||
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new: true\n"
|
||||
assert len(replace_calls) == 1
|
||||
assert replace_calls[0][0].parent == target.parent
|
||||
assert replace_calls[0][1] == target
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
def test_atomic_write_fsyncs_file_and_parent_directory(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Durability requires syncing both payload bytes and the committed rename."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
synced_types: list[str] = []
|
||||
real_fsync = os.fsync
|
||||
|
||||
def _recording_fsync(fd: int) -> None:
|
||||
mode = os.fstat(fd).st_mode
|
||||
synced_types.append("directory" if stat.S_ISDIR(mode) else "file")
|
||||
real_fsync(fd)
|
||||
|
||||
monkeypatch.setattr(os, "fsync", _recording_fsync)
|
||||
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert synced_types == ["file", "directory"]
|
||||
|
||||
|
||||
def test_fdopen_failure_closes_temp_descriptor(tmp_path: Path, monkeypatch) -> None:
|
||||
"""A wrapper-construction failure must not leak the mkstemp descriptor."""
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
captured_fd: list[int] = []
|
||||
real_mkstemp = __import__("tempfile").mkstemp
|
||||
|
||||
def _capturing_mkstemp(*args, **kwargs):
|
||||
fd, name = real_mkstemp(*args, **kwargs)
|
||||
captured_fd.append(fd)
|
||||
return fd, name
|
||||
|
||||
def _failing_fdopen(*_args, **_kwargs):
|
||||
raise OSError("simulated fdopen failure")
|
||||
|
||||
from api import paths
|
||||
|
||||
monkeypatch.setattr(paths.tempfile, "mkstemp", _capturing_mkstemp)
|
||||
monkeypatch.setattr(os, "fdopen", _failing_fdopen)
|
||||
|
||||
with pytest.raises(OSError, match="simulated fdopen failure"):
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
|
||||
assert len(captured_fd) == 1
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
os.fstat(captured_fd[0])
|
||||
assert exc_info.value.errno == errno.EBADF
|
||||
assert target.read_text(encoding="utf-8") == "old: true\n"
|
||||
|
||||
|
||||
def test_concurrent_writers_expose_only_complete_versions(tmp_path: Path) -> None:
|
||||
"""Concurrent saves may be last-writer-wins, but never partial or mixed."""
|
||||
target = tmp_path / "config.yaml"
|
||||
original = b"model:\n default: original\n"
|
||||
payloads = [
|
||||
f"model:\n default: writer-{index}\n padding: {'x' * 200_000}\n".encode()
|
||||
for index in range(8)
|
||||
]
|
||||
target.write_bytes(original)
|
||||
valid_versions = {original, *payloads}
|
||||
invalid_versions: list[bytes] = []
|
||||
reading = threading.Event()
|
||||
reading.set()
|
||||
|
||||
def _observe() -> None:
|
||||
while reading.is_set():
|
||||
observed = target.read_bytes()
|
||||
if observed not in valid_versions:
|
||||
invalid_versions.append(observed)
|
||||
return
|
||||
time.sleep(0.001)
|
||||
|
||||
observer = threading.Thread(target=_observe)
|
||||
observer.start()
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=len(payloads)) as pool:
|
||||
list(pool.map(lambda payload: _atomic_write_text(target, payload.decode()), payloads))
|
||||
finally:
|
||||
reading.clear()
|
||||
observer.join()
|
||||
|
||||
assert invalid_versions == []
|
||||
assert target.read_bytes() in payloads
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
def test_forced_fallback_serializes_writers_and_syncs_only_complete_versions(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""The non-atomic compatibility path must not interleave WebUI writers."""
|
||||
target = tmp_path / "config.yaml"
|
||||
payloads = [
|
||||
f"writer: {index}\npadding: {'x' * 20_000}\n".encode()
|
||||
for index in range(6)
|
||||
]
|
||||
valid_versions = set(payloads)
|
||||
observed_at_sync: list[bytes] = []
|
||||
active_writers = 0
|
||||
max_active_writers = 0
|
||||
state_lock = threading.Lock()
|
||||
real_fdopen = os.fdopen
|
||||
real_fsync = os.fsync
|
||||
|
||||
from api import paths
|
||||
|
||||
def _force_fallback(*_args, **_kwargs):
|
||||
raise PermissionError("simulated non-writable parent")
|
||||
|
||||
class _SlowWriter:
|
||||
def __init__(self, wrapped):
|
||||
self._wrapped = wrapped
|
||||
|
||||
def __enter__(self):
|
||||
nonlocal active_writers, max_active_writers
|
||||
self._wrapped.__enter__()
|
||||
with state_lock:
|
||||
active_writers += 1
|
||||
max_active_writers = max(max_active_writers, active_writers)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
nonlocal active_writers
|
||||
try:
|
||||
return self._wrapped.__exit__(*args)
|
||||
finally:
|
||||
with state_lock:
|
||||
active_writers -= 1
|
||||
|
||||
def write(self, value: str) -> None:
|
||||
midpoint = len(value) // 2
|
||||
self._wrapped.write(value[:midpoint])
|
||||
self._wrapped.flush()
|
||||
time.sleep(0.01)
|
||||
self._wrapped.write(value[midpoint:])
|
||||
|
||||
def flush(self) -> None:
|
||||
self._wrapped.flush()
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self._wrapped.fileno()
|
||||
|
||||
def _slow_fdopen(fd: int, *args, **kwargs):
|
||||
return _SlowWriter(real_fdopen(fd, *args, **kwargs))
|
||||
|
||||
def _observing_fsync(fd: int) -> None:
|
||||
if stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
observed_at_sync.append(target.read_bytes())
|
||||
real_fsync(fd)
|
||||
|
||||
target.write_bytes(payloads[0])
|
||||
monkeypatch.setattr(paths.tempfile, "mkstemp", _force_fallback)
|
||||
monkeypatch.setattr(os, "fdopen", _slow_fdopen)
|
||||
monkeypatch.setattr(os, "fsync", _observing_fsync)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(payloads)) as pool:
|
||||
list(pool.map(lambda payload: _atomic_write_text(target, payload.decode()), payloads))
|
||||
|
||||
assert max_active_writers == 1
|
||||
assert len(observed_at_sync) == len(payloads)
|
||||
assert all(observed in valid_versions for observed in observed_at_sync)
|
||||
assert target.read_bytes() in valid_versions
|
||||
|
||||
|
||||
def test_new_files_follow_the_current_umask_without_a_global_probe(tmp_path: Path) -> None:
|
||||
"""New config files use the kernel's current umask without mutating it.
|
||||
|
||||
Importing this helper can occur on a request thread, so caching a mode by
|
||||
calling ``os.umask`` at import time is still racy. A subprocess isolates the
|
||||
process-global umask and proves two writes after import follow two different
|
||||
active masks.
|
||||
"""
|
||||
script = """
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from api.paths import _atomic_write_text
|
||||
|
||||
base = Path(sys.argv[1])
|
||||
os.umask(0o077)
|
||||
_atomic_write_text(base / 'private.yaml', 'private: true\\n')
|
||||
os.umask(0o022)
|
||||
_atomic_write_text(base / 'shared.yaml', 'shared: true\\n')
|
||||
print(oct(stat.S_IMODE(os.stat(base / 'private.yaml').st_mode)))
|
||||
print(oct(stat.S_IMODE(os.stat(base / 'shared.yaml').st_mode)))
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script, str(tmp_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.stdout.splitlines() == ["0o600", "0o644"]
|
||||
|
||||
|
||||
def test_atomic_write_follows_config_symlink(tmp_path: Path) -> None:
|
||||
"""Writing through a config.yaml symlink updates the target, not the link.
|
||||
|
||||
``Path.write_text`` follows symlinks. The atomic rewrite must preserve that
|
||||
contract because ``HERMES_CONFIG_PATH`` and profile config paths may point at
|
||||
a shared config via symlink; replacing the symlink itself would silently
|
||||
sever the user's chosen config location.
|
||||
"""
|
||||
target_dir = tmp_path / "target"
|
||||
link_dir = tmp_path / "link"
|
||||
target_dir.mkdir()
|
||||
link_dir.mkdir()
|
||||
target = target_dir / "config.yaml"
|
||||
link = link_dir / "config.yaml"
|
||||
target.write_text("model:\n default: old\n", encoding="utf-8")
|
||||
os.chmod(target, 0o644)
|
||||
link.symlink_to(target)
|
||||
|
||||
_atomic_write_text(link, "model:\n default: new\n")
|
||||
|
||||
assert link.is_symlink()
|
||||
assert os.readlink(link) == str(target)
|
||||
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
assert link.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
assert (os.stat(target).st_mode & 0o777) == 0o644
|
||||
assert [p.name for p in link_dir.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
def test_atomic_write_preserves_hard_link_aliases(tmp_path: Path) -> None:
|
||||
"""Path.write_text updates a shared inode instead of severing hard links."""
|
||||
target = tmp_path / "config.yaml"
|
||||
alias = tmp_path / "shared-config.yaml"
|
||||
target.write_text("model:\n default: old\n", encoding="utf-8")
|
||||
os.link(target, alias)
|
||||
inode = os.stat(target).st_ino
|
||||
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
assert alias.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
assert os.stat(target).st_ino == inode
|
||||
assert os.stat(alias).st_ino == inode
|
||||
assert os.stat(target).st_nlink == 2
|
||||
|
||||
|
||||
def test_symlink_retarget_during_write_aborts_without_touching_either_target(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A concurrent symlink retarget must not commit to the stale referent."""
|
||||
old_target = tmp_path / "old.yaml"
|
||||
new_target = tmp_path / "new.yaml"
|
||||
link = tmp_path / "config.yaml"
|
||||
old_target.write_text("old-target: true\n", encoding="utf-8")
|
||||
new_target.write_text("new-target: true\n", encoding="utf-8")
|
||||
link.symlink_to(old_target)
|
||||
real_fsync = os.fsync
|
||||
retargeted = False
|
||||
|
||||
def _retarget_after_payload_sync(fd: int) -> None:
|
||||
nonlocal retargeted
|
||||
real_fsync(fd)
|
||||
if not retargeted and stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
link.unlink()
|
||||
link.symlink_to(new_target)
|
||||
retargeted = True
|
||||
|
||||
monkeypatch.setattr(os, "fsync", _retarget_after_payload_sync)
|
||||
|
||||
with pytest.raises(RuntimeError, match="symlink target changed"):
|
||||
_atomic_write_text(link, "replacement: true\n")
|
||||
|
||||
assert link.resolve() == new_target
|
||||
assert old_target.read_text(encoding="utf-8") == "old-target: true\n"
|
||||
assert new_target.read_text(encoding="utf-8") == "new-target: true\n"
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == [
|
||||
"config.yaml",
|
||||
"new.yaml",
|
||||
"old.yaml",
|
||||
]
|
||||
|
||||
|
||||
def test_partial_temp_write_failure_leaves_old_file_intact(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A failure after writing some temp bytes must preserve valid old YAML."""
|
||||
target = tmp_path / "config.yaml"
|
||||
original = "model:\n default: keep-me\n"
|
||||
target.write_text(original, encoding="utf-8")
|
||||
|
||||
class _PartialWriter:
|
||||
def __init__(self, fd: int):
|
||||
self.fd = fd
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
os.close(self.fd)
|
||||
|
||||
def write(self, value: str) -> None:
|
||||
os.write(self.fd, value[:8].encode("utf-8"))
|
||||
raise OSError("simulated partial temp write")
|
||||
|
||||
def _partial_fdopen(fd: int, *_args, **_kwargs):
|
||||
return _PartialWriter(fd)
|
||||
|
||||
monkeypatch.setattr(os, "fdopen", _partial_fdopen)
|
||||
|
||||
with pytest.raises(OSError, match="simulated partial temp write"):
|
||||
_atomic_write_text(target, "model:\n default: replacement\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
def test_encoding_and_newline_bytes_match_path_write_text(tmp_path: Path) -> None:
|
||||
"""The atomic helper preserves the prior writer's encoding/newline contract."""
|
||||
target = tmp_path / "config.yaml"
|
||||
expected = tmp_path / "expected.yaml"
|
||||
text = "greeting: Grüß dich\r\nnext: line\n"
|
||||
expected.write_text(text, encoding="utf-16")
|
||||
|
||||
_atomic_write_text(target, text, encoding="utf-16")
|
||||
|
||||
assert target.read_bytes() == expected.read_bytes()
|
||||
|
||||
|
||||
def test_symlink_chain_updates_final_referent(tmp_path: Path) -> None:
|
||||
target = tmp_path / "real.yaml"
|
||||
middle = tmp_path / "middle.yaml"
|
||||
outer = tmp_path / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
middle.symlink_to(target)
|
||||
outer.symlink_to(middle)
|
||||
|
||||
_atomic_write_text(outer, "new: true\n")
|
||||
|
||||
assert outer.is_symlink()
|
||||
assert middle.is_symlink()
|
||||
assert target.read_text(encoding="utf-8") == "new: true\n"
|
||||
|
||||
|
||||
def test_failed_write_leaves_old_file_intact(tmp_path: Path, monkeypatch) -> None:
|
||||
"""A crash at the os.replace step must not touch the original file."""
|
||||
target = tmp_path / "config.yaml"
|
||||
original = "model:\n default: keep-me\n"
|
||||
target.write_text(original, encoding="utf-8")
|
||||
|
||||
boom = RuntimeError("simulated crash mid-write")
|
||||
|
||||
def _failing_replace(src, dst):
|
||||
raise boom
|
||||
|
||||
monkeypatch.setattr(os, "replace", _failing_replace)
|
||||
|
||||
with pytest.raises(RuntimeError, match="simulated crash mid-write"):
|
||||
_atomic_write_text(target, "model:\n default: half-written\n")
|
||||
|
||||
# Original config survives untouched — the whole point of the fix.
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
# And the temp file was cleaned up rather than left as debris.
|
||||
leftovers = [p.name for p in tmp_path.iterdir() if p.name != "config.yaml"]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_failed_write_through_symlink_leaves_link_and_target_intact(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A failed symlink write must not replace the symlink or truncate target."""
|
||||
target_dir = tmp_path / "target"
|
||||
link_dir = tmp_path / "link"
|
||||
target_dir.mkdir()
|
||||
link_dir.mkdir()
|
||||
target = target_dir / "config.yaml"
|
||||
link = link_dir / "config.yaml"
|
||||
original = "model:\n default: keep-me\n"
|
||||
target.write_text(original, encoding="utf-8")
|
||||
link.symlink_to(target)
|
||||
|
||||
boom = RuntimeError("simulated crash mid-write")
|
||||
|
||||
def _failing_replace(src, dst):
|
||||
raise boom
|
||||
|
||||
monkeypatch.setattr(os, "replace", _failing_replace)
|
||||
|
||||
with pytest.raises(RuntimeError, match="simulated crash mid-write"):
|
||||
_atomic_write_text(link, "model:\n default: half-written\n")
|
||||
|
||||
assert link.is_symlink()
|
||||
assert os.readlink(link) == str(target)
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
assert [p.name for p in link_dir.iterdir()] == ["config.yaml"]
|
||||
assert [p.name for p in target_dir.iterdir()] == ["config.yaml"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"writer", ["main", "onboarding", "profile_endpoint", "profile_defaults"]
|
||||
)
|
||||
def test_each_config_writer_preserves_old_bytes_when_replace_fails(
|
||||
tmp_path: Path, monkeypatch, writer: str
|
||||
) -> None:
|
||||
"""Pin every config.yaml writer to the shared atomic failure contract."""
|
||||
original = b"model:\n default: keep-me\n"
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_bytes(original)
|
||||
|
||||
def _failing_replace(_src, _dst):
|
||||
raise OSError("simulated replace failure")
|
||||
|
||||
monkeypatch.setattr(os, "replace", _failing_replace)
|
||||
|
||||
with pytest.raises(OSError, match="simulated replace failure"):
|
||||
if writer == "main":
|
||||
from api import config
|
||||
|
||||
config._save_yaml_config_file(target, {"model": {"default": "new"}})
|
||||
elif writer == "onboarding":
|
||||
from api import onboarding
|
||||
|
||||
onboarding._save_yaml_config(
|
||||
target, {"model": {"default": "replacement-model"}}
|
||||
)
|
||||
else:
|
||||
from api import profiles
|
||||
|
||||
if writer == "profile_endpoint":
|
||||
profiles._write_endpoint_to_config(tmp_path, base_url="https://example.invalid")
|
||||
else:
|
||||
profiles._write_model_defaults_to_config(
|
||||
tmp_path, default_model="replacement-model"
|
||||
)
|
||||
|
||||
assert target.read_bytes() == original
|
||||
|
||||
|
||||
_ROOT_SKIP = pytest.mark.skipif(
|
||||
getattr(os, "geteuid", lambda: -1)() == 0,
|
||||
reason="root bypasses directory permission bits",
|
||||
)
|
||||
|
||||
|
||||
@_ROOT_SKIP
|
||||
def test_readonly_parent_with_writable_file_falls_back_to_write_through(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A locked-down config dir with a writable config.yaml must still save.
|
||||
|
||||
Hardened deployments (documented ``HERMES_CONFIG_PATH`` layouts) keep the
|
||||
containing directory read-only while leaving ``config.yaml`` itself
|
||||
writable. The old ``Path.write_text`` only needed file write permission;
|
||||
``mkstemp(dir=parent)`` needs DIRECTORY write permission, so without the
|
||||
fallback every settings/onboarding/profile save would start failing there.
|
||||
"""
|
||||
cfg_dir = tmp_path / "locked"
|
||||
cfg_dir.mkdir()
|
||||
target = cfg_dir / "config.yaml"
|
||||
target.write_text("model:\n default: old\n", encoding="utf-8")
|
||||
os.chmod(target, 0o644)
|
||||
os.chmod(cfg_dir, 0o555)
|
||||
try:
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "model:\n default: new\n"
|
||||
# In-place write-through keeps the file's mode and leaves no debris.
|
||||
assert (os.stat(target).st_mode & 0o777) == 0o644
|
||||
assert [p.name for p in cfg_dir.iterdir()] == ["config.yaml"]
|
||||
finally:
|
||||
os.chmod(cfg_dir, 0o755)
|
||||
|
||||
|
||||
@_ROOT_SKIP
|
||||
def test_writable_unreadable_parent_still_commits_atomically(tmp_path: Path) -> None:
|
||||
"""A best-effort directory fsync must not reject a writable 0333 parent."""
|
||||
cfg_dir = tmp_path / "cfg"
|
||||
cfg_dir.mkdir()
|
||||
target = cfg_dir / "config.yaml"
|
||||
target.write_text("old: true\n", encoding="utf-8")
|
||||
os.chmod(cfg_dir, 0o333)
|
||||
try:
|
||||
_atomic_write_text(target, "new: true\n")
|
||||
assert target.read_text(encoding="utf-8") == "new: true\n"
|
||||
finally:
|
||||
os.chmod(cfg_dir, 0o755)
|
||||
|
||||
|
||||
def test_fallback_rejects_target_swap_before_open(tmp_path: Path, monkeypatch) -> None:
|
||||
"""The non-atomic fallback must not overwrite a swapped-in unrelated inode."""
|
||||
target = tmp_path / "config.yaml"
|
||||
victim = tmp_path / "victim.yaml"
|
||||
target.write_text("original: true\n", encoding="utf-8")
|
||||
victim.write_text("victim: keep\n", encoding="utf-8")
|
||||
|
||||
from api import paths
|
||||
|
||||
def _swap_then_deny(*_args, **_kwargs):
|
||||
target.unlink()
|
||||
target.symlink_to(victim)
|
||||
raise PermissionError("simulated non-writable parent")
|
||||
|
||||
monkeypatch.setattr(paths.tempfile, "mkstemp", _swap_then_deny)
|
||||
|
||||
with pytest.raises(PermissionError, match="changed before fallback"):
|
||||
_atomic_write_text(target, "victim: overwritten\n")
|
||||
|
||||
assert target.is_symlink()
|
||||
assert victim.read_text(encoding="utf-8") == "victim: keep\n"
|
||||
|
||||
|
||||
@_ROOT_SKIP
|
||||
def test_readonly_parent_without_writable_target_still_raises(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The fallback is scoped to existing writable files, not a broad catch.
|
||||
|
||||
With no target file to write through (or an unwritable one), swallowing
|
||||
the ``PermissionError`` would turn a genuinely misconfigured deployment
|
||||
into a silent no-op save — the error must keep propagating.
|
||||
"""
|
||||
cfg_dir = tmp_path / "locked"
|
||||
cfg_dir.mkdir()
|
||||
missing = cfg_dir / "config.yaml"
|
||||
os.chmod(cfg_dir, 0o555)
|
||||
try:
|
||||
with pytest.raises(PermissionError):
|
||||
_atomic_write_text(missing, "model:\n default: new\n")
|
||||
assert not missing.exists()
|
||||
finally:
|
||||
os.chmod(cfg_dir, 0o755)
|
||||
|
||||
|
||||
@_ROOT_SKIP
|
||||
def test_writable_parent_with_readonly_file_raises_and_keeps_bytes(tmp_path: Path) -> None:
|
||||
"""A deliberately locked config (0444) in a writable directory must keep
|
||||
rejecting writes: without the target-writability probe, atomic replace
|
||||
creates a fresh temp inode and renames over the read-only file."""
|
||||
cfg_dir = tmp_path / "open-dir"
|
||||
cfg_dir.mkdir()
|
||||
target = cfg_dir / "config.yaml"
|
||||
original = "model:\n default: keep-me\n"
|
||||
target.write_text(original, encoding="utf-8")
|
||||
os.chmod(target, 0o444)
|
||||
os.chmod(cfg_dir, 0o755)
|
||||
try:
|
||||
with pytest.raises(PermissionError):
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
assert stat.S_IMODE(os.stat(target).st_mode) == 0o444
|
||||
finally:
|
||||
os.chmod(target, 0o644)
|
||||
|
||||
|
||||
def test_readonly_parent_with_unwritable_file_still_raises(tmp_path: Path) -> None:
|
||||
cfg_dir = tmp_path / "locked"
|
||||
cfg_dir.mkdir()
|
||||
target = cfg_dir / "config.yaml"
|
||||
original = "model:\n default: keep-me\n"
|
||||
target.write_text(original, encoding="utf-8")
|
||||
os.chmod(target, 0o444)
|
||||
os.chmod(cfg_dir, 0o555)
|
||||
try:
|
||||
with pytest.raises(PermissionError):
|
||||
_atomic_write_text(target, "model:\n default: new\n")
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
finally:
|
||||
os.chmod(cfg_dir, 0o755)
|
||||
os.chmod(target, 0o644)
|
||||
@@ -595,8 +595,11 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False)
|
||||
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_runner_enabled", lambda: False)
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_config",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("_start_run reread shared config")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"ensure_agent_runtime_current",
|
||||
@@ -621,6 +624,7 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch):
|
||||
normalized_model=False,
|
||||
source="webui",
|
||||
route="/api/chat/start",
|
||||
gateway_chat_enabled=True,
|
||||
)
|
||||
|
||||
assert response["stream_id"] == "gateway-stream-1"
|
||||
|
||||
@@ -680,6 +680,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
function _formatGatewayModelLabel() {{ return ''; }}
|
||||
function _gatewayRoutingFailoverText() {{ return ''; }}
|
||||
function _gatewayModelWarningText() {{ return ''; }}
|
||||
function _usedModelTurnChipLabel() {{ return ''; }}
|
||||
function _formatTurnDuration() {{ return ''; }}
|
||||
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx) {{
|
||||
const group = new FakeElement('div');
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Regression coverage for anchor live prose sealing + row-level parser finalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
NODE = shutil.which("node")
|
||||
|
||||
EXTRACT_FN_JS = """
|
||||
function hasFunction(src, name){
|
||||
return src.indexOf('function ' + name) !== -1;
|
||||
}
|
||||
|
||||
function extractFn(src, name){
|
||||
const start = src.indexOf('function ' + name);
|
||||
if(start === -1) throw new Error(name + ' not found');
|
||||
const paramsStart = src.indexOf('(', start);
|
||||
let depth = 0;
|
||||
let paramsEnd = -1;
|
||||
for(let i = paramsStart; i < src.length; i++){
|
||||
if(src[i] === '(') depth += 1;
|
||||
else if(src[i] === ')'){
|
||||
depth -= 1;
|
||||
if(depth === 0){ paramsEnd = i; break; }
|
||||
}
|
||||
}
|
||||
const braceStart = src.indexOf('{', paramsEnd);
|
||||
if(braceStart === -1) throw new Error(name + ' body not found');
|
||||
depth = 0;
|
||||
for(let i = braceStart; i < src.length; i++){
|
||||
if(src[i] === '{') depth += 1;
|
||||
else if(src[i] === '}'){
|
||||
depth -= 1;
|
||||
if(depth === 0){
|
||||
return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(name + ' body did not close');
|
||||
}
|
||||
"""
|
||||
|
||||
HARNESS_JS_TEMPLATE = r"""
|
||||
const fs = require('fs');
|
||||
const msgSrc = fs.readFileSync(__MSG_SRC__, 'utf8');
|
||||
const uiSrc = fs.readFileSync(__UI_SRC__, 'utf8');
|
||||
let _extractSource = msgSrc;
|
||||
let _anchorProseSmdCache;
|
||||
let _anchorProseIncrementalNode;
|
||||
let _finalizeAnchorProseIncrementalNode;
|
||||
let _anchorSceneNodeForRow;
|
||||
|
||||
function extractFunction(name){
|
||||
return extractFn(_extractSource, name);
|
||||
}
|
||||
__EXTRACT_FN_JS__
|
||||
|
||||
function createHarness(options={}){
|
||||
const calls = {written:0, ended:0, failedEnds:0, created:0, tailFlush:0, tailClear:0, sanitize:0, enhance:0, identityClear:0};
|
||||
const cache = new Map();
|
||||
const failParserEnd = !!(options && options.failParserEnd);
|
||||
class FakeElement{
|
||||
constructor(tagName='div'){
|
||||
this.tagName = String(tagName || 'div').toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = Object.create(null);
|
||||
this.dataset = Object.create(null);
|
||||
this.parentNode = null;
|
||||
this.style = {};
|
||||
this._textContent = '';
|
||||
this._classSet = new Set();
|
||||
this.classList = {
|
||||
add: (name)=>this._classSet.add(String(name)),
|
||||
remove: (name)=>this._classSet.delete(String(name)),
|
||||
toggle: (name, on)=>{
|
||||
const key = String(name);
|
||||
if(on === false) this._classSet.delete(key);
|
||||
else this._classSet.add(key);
|
||||
},
|
||||
contains: (name)=>this._classSet.has(String(name)),
|
||||
};
|
||||
}
|
||||
appendChild(child){
|
||||
if(!child) return null;
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
get className(){
|
||||
return Array.from(this._classSet).join(' ');
|
||||
}
|
||||
set className(value){
|
||||
this._classSet.clear();
|
||||
String(value || '').split(/\s+/).filter(Boolean).forEach(v=>this._classSet.add(v));
|
||||
}
|
||||
setAttribute(name, value){
|
||||
const key = String(name);
|
||||
const val = String(value);
|
||||
this.attributes[key] = val;
|
||||
if(key === 'class'){
|
||||
this.className = val;
|
||||
}
|
||||
if(key.startsWith('data-')){
|
||||
const ds = key.slice(5).replace(/-([a-z])/g, (_, c)=>c.toUpperCase());
|
||||
this.dataset[ds] = val;
|
||||
}
|
||||
}
|
||||
getAttribute(name){
|
||||
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
|
||||
}
|
||||
querySelector(selector){
|
||||
if(selector === '.msg-body'){
|
||||
const direct = this.children.find((c)=>c.className && c.className.split(' ').includes('msg-body')) || null;
|
||||
if(direct) return direct;
|
||||
if(typeof this._innerHTML === 'string'){
|
||||
const html = this._innerHTML || '';
|
||||
const open = '<div class=\"msg-body\">';
|
||||
const close = '</div>';
|
||||
if(html.startsWith(open) && html.endsWith(close)){
|
||||
return {
|
||||
textContent: html.slice(open.length, html.length - close.length),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
get innerHTML(){
|
||||
return this._innerHTML || '';
|
||||
}
|
||||
set innerHTML(value){
|
||||
this._innerHTML = String(value);
|
||||
this.textContent = this._innerHTML;
|
||||
}
|
||||
get textContent(){
|
||||
return this._textContent || '';
|
||||
}
|
||||
set textContent(value){
|
||||
this._textContent = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
global.document = {
|
||||
createElement(tag){
|
||||
return new FakeElement(tag);
|
||||
},
|
||||
createTextNode(value){
|
||||
const node = new FakeElement('#text');
|
||||
node.textContent = String(value || '');
|
||||
return node;
|
||||
},
|
||||
};
|
||||
|
||||
function bindParser(renderer, parser, el){
|
||||
if(!parser || !el) return;
|
||||
parser._body = el;
|
||||
el.__smdParser = parser;
|
||||
}
|
||||
|
||||
global.window = {
|
||||
_fadeTextEffect: false,
|
||||
_shouldUseLiveProseFade: ()=>false,
|
||||
};
|
||||
window.__anchorProseIncrementalNode = null;
|
||||
window._anchorProseSmdCache = cache;
|
||||
window.smd = {
|
||||
HREF: 'href',
|
||||
SRC: 'src',
|
||||
parser(){
|
||||
calls.created += 1;
|
||||
return {id: calls.created, ended: false, pending: ''};
|
||||
},
|
||||
parser_write(parser, text){
|
||||
calls.written += 1;
|
||||
if(!parser || parser.ended || !parser._body) return;
|
||||
const chunk = String(text || '');
|
||||
if(!chunk) return;
|
||||
const flushNow = chunk.length > 1 ? chunk.slice(0, -1) : '';
|
||||
if(flushNow) parser._body.textContent += flushNow;
|
||||
parser.pending += chunk.slice(-1);
|
||||
},
|
||||
parser_end(parser){
|
||||
calls.ended += 1;
|
||||
if(failParserEnd){
|
||||
calls.failedEnds += 1;
|
||||
throw new Error('parser_end failure');
|
||||
}
|
||||
if(!parser || parser.ended || !parser._body) return;
|
||||
parser._body.textContent += parser.pending || '';
|
||||
parser.pending = '';
|
||||
parser.ended = true;
|
||||
},
|
||||
};
|
||||
|
||||
global._safeSmdRenderer = ()=>({set_attr(){},add_text(){},add_token(){},end_token(){}});
|
||||
global._smdRendererWithoutUnderscoreEmphasis = (renderer)=>renderer;
|
||||
global._streamFadeRenderer = (body)=>({body,set_attr(){},add_text(){},add_token(){},end_token(){}});
|
||||
global._smdBindParserIdentity = bindParser;
|
||||
global._smdClearParserIdentity = (el, parser)=>{
|
||||
if(!el || !parser || el.__smdParser !== parser) return;
|
||||
calls.identityClear += 1;
|
||||
delete el.__smdParser;
|
||||
};
|
||||
global._smdMediaTailFlush = ()=>{ calls.tailFlush += 1; };
|
||||
global._smdMediaTailClear = ()=>{ calls.tailClear += 1; };
|
||||
global._sanitizeSmdLinks = ()=>{ calls.sanitize += 1; };
|
||||
global.enhanceMarkdownTables = ()=>{ calls.enhance += 1; };
|
||||
global.renderMd = (text)=>String(text || '');
|
||||
global.esc = (value)=>String(value || '');
|
||||
|
||||
_anchorProseSmdCache = cache;
|
||||
window._anchorProseSmdCache = cache;
|
||||
_extractSource = msgSrc;
|
||||
eval(extractFunction('_anchorProseIncrementalNode'));
|
||||
eval(extractFunction('_finalizeAnchorProseIncrementalNode'));
|
||||
_extractSource = uiSrc;
|
||||
eval(extractFunction('_anchorSceneNodeForRow'));
|
||||
window.__anchorProseIncrementalNode = _anchorProseIncrementalNode;
|
||||
|
||||
return {
|
||||
calls,
|
||||
cache,
|
||||
renderRow(row){
|
||||
return _anchorSceneNodeForRow(row, {settled:false});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runCase(name){
|
||||
const harness = createHarness();
|
||||
const row = (status, text)=>({role:'prose', local_id:'live-prose:1', status, text});
|
||||
|
||||
if(name === 'running_row_does_not_finalize'){
|
||||
const node = harness.renderRow(row('running', 'running text.'));
|
||||
return {
|
||||
finalizeCount: harness.calls.ended,
|
||||
hasNode: !!node,
|
||||
};
|
||||
}
|
||||
|
||||
if(name === 'completed_row_finalizes_once'){
|
||||
harness.renderRow(row('running', 'streamed final'));
|
||||
const node = harness.renderRow(row('completed', 'streamed final.'));
|
||||
const body = node && node.querySelector('.msg-body');
|
||||
return {
|
||||
endCount: harness.calls.ended,
|
||||
bodyText: body ? body.textContent : '',
|
||||
sanitizeCalls: harness.calls.sanitize,
|
||||
enhanceCalls: harness.calls.enhance,
|
||||
tailFlush: harness.calls.tailFlush,
|
||||
tailClear: harness.calls.tailClear,
|
||||
};
|
||||
}
|
||||
|
||||
if(name === 'completed_row_rerender_is_idempotent'){
|
||||
harness.renderRow(row('completed', 'idempotent.'));
|
||||
const node = harness.renderRow(row('completed', 'idempotent.'));
|
||||
const body = node && node.querySelector('.msg-body');
|
||||
return {
|
||||
endCount: harness.calls.ended,
|
||||
bodyText: body ? body.textContent : '',
|
||||
createdParsers: harness.calls.created,
|
||||
writeCalls: harness.calls.written,
|
||||
};
|
||||
}
|
||||
|
||||
if(name === 'finalized_row_rebuilds_on_changed_text'){
|
||||
harness.renderRow(row('completed', 'first.'));
|
||||
const node = harness.renderRow(row('completed', 'first!!'));
|
||||
const body = node && node.querySelector('.msg-body');
|
||||
return {
|
||||
endCount: harness.calls.ended,
|
||||
createdParsers: harness.calls.created,
|
||||
bodyText: body ? body.textContent : '',
|
||||
writeCalls: harness.calls.written,
|
||||
};
|
||||
}
|
||||
|
||||
if(name === 'tool_boundary_completes_row'){
|
||||
harness.renderRow(row('running', 'pre tool'));
|
||||
const node = harness.renderRow(row('completed', 'pre tool.'));
|
||||
const body = node && node.querySelector('.msg-body');
|
||||
return {
|
||||
finalizeCount: harness.calls.ended,
|
||||
bodyText: body ? body.textContent : '',
|
||||
writeCalls: harness.calls.written,
|
||||
createdParsers: harness.calls.created,
|
||||
};
|
||||
}
|
||||
|
||||
if(name === 'completed_row_end_failure_falls_back_to_render_md'){
|
||||
const failingHarness = createHarness({failParserEnd:true});
|
||||
const node = failingHarness.renderRow({
|
||||
role:'prose',
|
||||
local_id:'live-prose:1',
|
||||
status:'completed',
|
||||
text:'streamed final.',
|
||||
});
|
||||
const body = node && node.querySelector('.msg-body');
|
||||
return {
|
||||
finalizeCount: failingHarness.calls.ended,
|
||||
failedEndCount: failingHarness.calls.failedEnds,
|
||||
bodyText: body ? body.textContent : '',
|
||||
renderCount: failingHarness.calls.created,
|
||||
writeCalls: failingHarness.calls.written,
|
||||
cacheSize: failingHarness.cache.size,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('unknown case: ' + name);
|
||||
}
|
||||
|
||||
const output = runCase("__CASE__");
|
||||
console.log(JSON.stringify(output));
|
||||
"""
|
||||
|
||||
|
||||
def _run_scenario(case_name: str) -> dict:
|
||||
assert NODE is not None, "node is required for anchor incremental harness tests"
|
||||
case = str(case_name)
|
||||
script = HARNESS_JS_TEMPLATE
|
||||
script = script.replace("__MSG_SRC__", json.dumps(str(ROOT / 'static' / 'messages.js')))
|
||||
script = script.replace("__UI_SRC__", json.dumps(str(ROOT / 'static' / 'ui.js')))
|
||||
script = script.replace("__EXTRACT_FN_JS__", EXTRACT_FN_JS)
|
||||
script = script.replace("__CASE__", case)
|
||||
result = subprocess.run([NODE, "-e", script], cwd=ROOT, text=True, capture_output=True, check=False)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _messages_js_source() -> str:
|
||||
return (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
|
||||
def test_running_anchor_prose_does_not_finalize_parser():
|
||||
data = _run_scenario("running_row_does_not_finalize")
|
||||
assert data["finalizeCount"] == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
|
||||
def test_completed_anchor_row_finalizes_once_and_runs_post_end_cleanup():
|
||||
data = _run_scenario("completed_row_finalizes_once")
|
||||
assert data["endCount"] == 1
|
||||
assert data["bodyText"] == "streamed final."
|
||||
assert data["sanitizeCalls"] >= 1
|
||||
assert data["enhanceCalls"] >= 1
|
||||
assert data["tailFlush"] >= 1
|
||||
assert data["tailClear"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
|
||||
def test_completed_row_rerender_is_idempotent_after_end():
|
||||
data = _run_scenario("completed_row_rerender_is_idempotent")
|
||||
assert data["endCount"] == 1
|
||||
assert data["bodyText"] == "idempotent."
|
||||
assert data["createdParsers"] == 1
|
||||
assert data["writeCalls"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
|
||||
def test_changed_text_after_finalized_row_rebuilds_from_full_authoritative_text():
|
||||
data = _run_scenario("finalized_row_rebuilds_on_changed_text")
|
||||
assert data["endCount"] == 2
|
||||
assert data["createdParsers"] == 2
|
||||
assert data["bodyText"] == "first!!"
|
||||
assert data["writeCalls"] >= 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
|
||||
def test_tool_boundary_sealing_updates_row_to_completed_for_finalize():
|
||||
messages_source = _messages_js_source()
|
||||
assert "_upsertAnchorProcessProse(pendingDisplayTextBeforeTool,{sealed:true})" in messages_source
|
||||
assert "_upsertAnchorProcessProse(pendingDisplayTextBeforeComplete,{sealed:true})" in messages_source
|
||||
assert "status:options.sealed?'completed':'running'" in messages_source
|
||||
data = _run_scenario("tool_boundary_completes_row")
|
||||
assert data["finalizeCount"] == 1
|
||||
assert data["bodyText"] == "pre tool."
|
||||
assert data["createdParsers"] == 1
|
||||
assert data["writeCalls"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for anchor prose incremental harness tests")
|
||||
def test_anchor_prose_parser_end_failure_falls_back_to_full_renderMd():
|
||||
data = _run_scenario("completed_row_end_failure_falls_back_to_render_md")
|
||||
assert data["finalizeCount"] == 1
|
||||
assert data["failedEndCount"] == 1
|
||||
assert data["renderCount"] == 1
|
||||
assert data["bodyText"] == "streamed final."
|
||||
assert data["cacheSize"] == 0
|
||||
@@ -2068,14 +2068,23 @@ def test_anchor_scene_hydration_seals_unmatched_live_running_activity_rows():
|
||||
"text": "only live prose",
|
||||
},
|
||||
{
|
||||
"row_id": "live-tool:stream-1:call-1",
|
||||
"local_id": "live-tool:stream-1:call-1",
|
||||
"row_id": "tool:call-1:0",
|
||||
"local_id": "call-1",
|
||||
"role": "tool",
|
||||
"kind": "tool_started",
|
||||
"source_event_type": "tool",
|
||||
"status": "running",
|
||||
"run_id": "run-1",
|
||||
"stream_id": "stream-1",
|
||||
"identity": {
|
||||
"local_id": "call-1",
|
||||
"run_id": "run-1",
|
||||
"stream_id": "stream-1",
|
||||
},
|
||||
"group": {"group_key": "segment:1", "activity_segment_seq": 1},
|
||||
"tool_call_id": "call-1",
|
||||
"tool": {"id": "call-1", "name": "terminal", "done": False},
|
||||
"payload": {"tid": "call-1", "status": "running", "done": False},
|
||||
},
|
||||
{"row_id": "done", "role": "terminal", "kind": "terminal_status", "source_event_type": "done"},
|
||||
],
|
||||
@@ -2089,6 +2098,29 @@ def test_anchor_scene_hydration_seals_unmatched_live_running_activity_rows():
|
||||
activity_rows = [row for row in rows if row.get("role") in {"thinking", "prose", "tool"}]
|
||||
assert [row.get("status") for row in activity_rows] == ["completed", "completed", "completed"]
|
||||
assert [row.get("role") for row in activity_rows] == ["thinking", "prose", "tool"]
|
||||
tool_row = activity_rows[-1]
|
||||
assert tool_row["tool"]["done"] is True
|
||||
assert tool_row["payload"]["status"] == "completed"
|
||||
assert tool_row["payload"]["done"] is True
|
||||
|
||||
|
||||
def test_anchor_scene_settlement_does_not_reclassify_transcript_owned_tool_row():
|
||||
from api import routes
|
||||
|
||||
row = {
|
||||
"row_id": "hydrated:stream-1:tool:call-1",
|
||||
"local_id": "call-1",
|
||||
"role": "tool",
|
||||
"status": "running",
|
||||
"stream_id": "stream-1",
|
||||
"group": {"assistant_msg_idx": 1},
|
||||
"tool": {"id": "call-1", "done": False},
|
||||
"payload": {"tid": "call-1", "status": "running", "done": False},
|
||||
}
|
||||
|
||||
assert routes._anchor_scene_settle_live_running_row(row, has_settled_thinking=False) is row
|
||||
assert row["tool"]["done"] is False
|
||||
assert row["payload"]["done"] is False
|
||||
|
||||
|
||||
def test_runtime_journal_anchor_scene_matches_settled_hydrated_visible_semantics(tmp_path, monkeypatch):
|
||||
|
||||
@@ -7,6 +7,8 @@ approval_id so /api/approval/respond can target a specific entry.
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
@@ -108,6 +110,39 @@ def test_polling_passes_count_to_show():
|
||||
"Poll loop must pass data.pending_count through showApprovalForSession"
|
||||
|
||||
|
||||
def test_chat_stream_approval_listener_renders_received_count():
|
||||
"""A chat-stream approval event with count two renders the head and count."""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
import pytest
|
||||
pytest.skip("node is required for the approval listener harness")
|
||||
start = MESSAGES_JS.index("source.addEventListener('approval',e=>{")
|
||||
end_marker = "\n });"
|
||||
end = MESSAGES_JS.index(end_marker, start) + len(end_marker)
|
||||
listener = MESSAGES_JS[start:end]
|
||||
script = f"""
|
||||
const rendered = [];
|
||||
let activeSid = 'sid-browser';
|
||||
const source = {{ listeners: {{}}, addEventListener(name, cb) {{ this.listeners[name] = cb; }} }};
|
||||
function _applyToAnchor() {{}}
|
||||
function showApprovalForSession(sid, data, count) {{ rendered.push({{sid, data, count}}); }}
|
||||
function playAttentionSound() {{}}
|
||||
function _attentionSoundKey() {{ return 'approval'; }}
|
||||
function sendBrowserNotification() {{}}
|
||||
{listener}
|
||||
source.listeners.approval({{ data: JSON.stringify({{command: 'head', approval_id: 'a', pending_count: 2}}) }});
|
||||
process.stdout.write(JSON.stringify(rendered));
|
||||
"""
|
||||
result = subprocess.run([node, "-e", script], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
rendered = json.loads(result.stdout)
|
||||
assert rendered == [{
|
||||
"sid": "sid-browser",
|
||||
"data": {"command": "head", "approval_id": "a", "pending_count": 2},
|
||||
"count": 2,
|
||||
}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML: counter element present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -113,8 +113,10 @@ class TestSSEStaticAnalysis:
|
||||
cb_start = streaming_src.find("def _approval_notify_cb(approval_data):")
|
||||
cb_end = streaming_src.find("_reg_notify(session_id, _approval_notify_cb)", cb_start)
|
||||
cb_body = streaming_src[cb_start:cb_end]
|
||||
assert "_submit_pending_for_polling(session_id, approval_data)" in cb_body, \
|
||||
assert "head, total = _submit_pending_for_polling(session_id, approval_data)" in cb_body, \
|
||||
"_approval_notify_cb must mirror approval data into polling state before SSE"
|
||||
assert '"pending_count": total' in cb_body, \
|
||||
"_approval_notify_cb must emit the reconciled pending count"
|
||||
assert "put('approval', approval_data)" in cb_body, \
|
||||
"_approval_notify_cb must still emit the approval SSE event"
|
||||
|
||||
|
||||
@@ -232,11 +232,57 @@ class TestApprovalModuleExports:
|
||||
assert cb_start != -1, "_approval_notify_cb must exist"
|
||||
cb_end = STREAMING_SRC.find("_reg_notify(session_id, _approval_notify_cb)", cb_start)
|
||||
cb_body = STREAMING_SRC[cb_start:cb_end]
|
||||
assert "_submit_pending_for_polling(session_id, approval_data)" in cb_body, \
|
||||
assert "head, total = _submit_pending_for_polling(session_id, approval_data)" in cb_body, \
|
||||
"approval notify callback must mirror approval data into polling state"
|
||||
assert '"pending_count": total' in cb_body, \
|
||||
"approval notify callback must publish the reconciled pending count"
|
||||
assert "put('approval', approval_data)" in cb_body, \
|
||||
"approval notify callback must still push the SSE event"
|
||||
|
||||
def test_reversed_local_callbacks_publish_all_parked_entries(self):
|
||||
"""The local producer must count every exact parked entry, not only its head."""
|
||||
import api.route_approvals as ra
|
||||
from api import routes
|
||||
|
||||
sid = f"unit-reversed-local-{uuid.uuid4().hex[:8]}"
|
||||
entry_a = _ApprovalEntry({
|
||||
"command": "same-command",
|
||||
"description": "same-description",
|
||||
"pattern_key": "same-pattern",
|
||||
"pattern_keys": ["same-pattern"],
|
||||
})
|
||||
entry_b = _ApprovalEntry(dict(entry_a.data))
|
||||
events = []
|
||||
with _lock:
|
||||
_gateway_queues[sid] = [entry_a, entry_b]
|
||||
ra._pending.pop(sid, None)
|
||||
|
||||
def local_producer(approval_data):
|
||||
head, total = ra.submit_gateway_pending_mirror(sid, approval_data)
|
||||
events.append((head, total))
|
||||
|
||||
try:
|
||||
local_producer(entry_b.data)
|
||||
local_producer(entry_a.data)
|
||||
|
||||
assert [total for _head, total in events] == [2, 2]
|
||||
assert all(head["command"] == "same-command" for head, _total in events)
|
||||
head_id = events[0][0]["approval_id"]
|
||||
assert head_id == events[1][0]["approval_id"]
|
||||
assert routes._resolve_approval_legacy(sid, head_id, "once") is True
|
||||
assert entry_a.event.is_set()
|
||||
assert not entry_b.event.is_set()
|
||||
with _lock:
|
||||
successor = ra._pending[sid][0]
|
||||
assert successor["approval_id"] != head_id
|
||||
with _lock:
|
||||
assert ra.reconcile_gateway_pending_mirror_locked(sid)[1] == 1
|
||||
finally:
|
||||
with _lock:
|
||||
ra._pending.pop(sid, None)
|
||||
_gateway_queues.pop(sid, None)
|
||||
ra._pending.pop(sid, None)
|
||||
|
||||
|
||||
# ── HTTP regression tests (test server, port 8788) ───────────────────────────
|
||||
|
||||
@@ -481,8 +527,8 @@ class TestApprovalHTTPEndpoints:
|
||||
r._gateway_queues.pop(sid, None)
|
||||
pass # no external token state to clean
|
||||
|
||||
def test_gateway_mirror_without_run_id_returns_explicit_conflict(self, monkeypatch):
|
||||
"""Gateway-mirrored approvals without relayable run state must stay actionable."""
|
||||
def test_gateway_mirror_without_run_id_and_no_producer_returns_explicit_conflict(self, monkeypatch):
|
||||
"""A no-run mirror stays actionable when no local producer is parked."""
|
||||
from api import routes as r
|
||||
from api import route_approvals as ra
|
||||
from api.gateway_chat import _STREAM_RUN_IDS
|
||||
@@ -515,7 +561,6 @@ class TestApprovalHTTPEndpoints:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
r._gateway_queues[sid] = [_ApprovalEntry(approval)]
|
||||
try:
|
||||
ra.submit_gateway_pending_mirror(sid, approval)
|
||||
with _lock:
|
||||
@@ -539,10 +584,172 @@ class TestApprovalHTTPEndpoints:
|
||||
assert isinstance(pending_queue, list)
|
||||
assert len(pending_queue) == 1
|
||||
assert pending_queue[0]["approval_id"] == approval_id
|
||||
assert sid in r._gateway_queues
|
||||
assert not r._gateway_queues[sid][0].event.is_set()
|
||||
assert sid not in r._gateway_queues
|
||||
finally:
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
|
||||
def test_gateway_mirror_without_run_id_with_one_producer_resolves_exactly(self, monkeypatch):
|
||||
"""A no-run mirror retires only after its exact local producer resolves."""
|
||||
from api import routes as r
|
||||
from api import route_approvals as ra
|
||||
from api.gateway_chat import _STREAM_RUN_IDS
|
||||
|
||||
sid = f"http-gateway-local-{uuid.uuid4().hex[:8]}"
|
||||
stream_id = f"stream-local-{uuid.uuid4().hex[:8]}"
|
||||
approval = {"command": "rm -rf /tmp/local", "description": "local"}
|
||||
sibling = {"command": "rm -rf /tmp/sibling", "description": "sibling"}
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, data, status=200, extra_headers=None):
|
||||
captured["payload"] = data
|
||||
captured["status"] = status
|
||||
return data
|
||||
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
monkeypatch.setattr(r, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
|
||||
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
||||
entry = _ApprovalEntry(approval)
|
||||
sibling_entry = _ApprovalEntry(sibling)
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues[sid] = [entry, sibling_entry]
|
||||
try:
|
||||
ra.submit_gateway_pending_mirror(sid, approval)
|
||||
with _lock:
|
||||
approval_id = r._pending[sid][0]["approval_id"]
|
||||
|
||||
r._handle_approval_respond(
|
||||
object(), {"session_id": sid, "choice": "once", "approval_id": approval_id}
|
||||
)
|
||||
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"] == {"ok": True, "choice": "once", "local_retired": True}
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
assert not sibling_entry.event.is_set()
|
||||
with _lock:
|
||||
assert r._pending[sid][0]["command"] == sibling["command"]
|
||||
finally:
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
|
||||
def test_gateway_no_run_mirror_survives_pending_and_attention_reads_until_explicit_teardown(self, monkeypatch):
|
||||
"""A missing-producer 409 stays visible until a real teardown retires it."""
|
||||
from api import routes as r
|
||||
from api import route_approvals as ra
|
||||
from api.gateway_chat import _STREAM_RUN_IDS, _cleanup_gateway_pending_mirror
|
||||
|
||||
sid = f"http-gateway-race-{uuid.uuid4().hex[:8]}"
|
||||
stream_id = f"stream-race-{uuid.uuid4().hex[:8]}"
|
||||
approval = {"command": "rm -rf /tmp/race", "description": "race"}
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, data, status=200, extra_headers=None):
|
||||
captured["payload"] = data
|
||||
captured["status"] = status
|
||||
return data
|
||||
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
monkeypatch.setattr(r, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
|
||||
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
||||
entry = _ApprovalEntry(approval)
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues[sid] = [entry]
|
||||
try:
|
||||
ra.submit_gateway_pending_mirror(sid, approval)
|
||||
with _lock:
|
||||
approval_id = r._pending[sid][0]["approval_id"]
|
||||
r._gateway_queues.pop(sid, None)
|
||||
|
||||
r._handle_approval_respond(
|
||||
object(), {"session_id": sid, "choice": "once", "approval_id": approval_id}
|
||||
)
|
||||
|
||||
assert captured["status"] == 409
|
||||
assert captured["payload"] == {
|
||||
"ok": False,
|
||||
"choice": "once",
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": r._GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
}
|
||||
assert not entry.event.is_set()
|
||||
pending = r._handle_approval_pending(
|
||||
object(), urllib.parse.urlparse(
|
||||
f"/api/approval/pending?session_id={urllib.parse.quote(sid)}"
|
||||
)
|
||||
)
|
||||
assert pending["pending"]["approval_id"] == approval_id
|
||||
assert pending["pending_count"] == 1
|
||||
assert r._session_attention_summary(sid) == {
|
||||
"kind": "approval", "count": 1, "severity": "critical"
|
||||
}
|
||||
_cleanup_gateway_pending_mirror(sid)
|
||||
pending = r._handle_approval_pending(
|
||||
object(), urllib.parse.urlparse(
|
||||
f"/api/approval/pending?session_id={urllib.parse.quote(sid)}"
|
||||
)
|
||||
)
|
||||
assert pending == {"pending": None, "pending_count": 0}
|
||||
assert r._session_attention_summary(sid) is None
|
||||
finally:
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
|
||||
def test_gateway_no_run_non_head_response_resolves_only_exact_producer(self, monkeypatch):
|
||||
"""An exact non-head response wakes only its matching producer."""
|
||||
from api import routes as r
|
||||
from api import route_approvals as ra
|
||||
|
||||
sid = f"http-gateway-non-head-{uuid.uuid4().hex[:8]}"
|
||||
approval_a = {"command": "rm -rf /tmp/a", "description": "a"}
|
||||
approval_b = {"command": "rm -rf /tmp/b", "description": "b"}
|
||||
entry_a = _ApprovalEntry(approval_a)
|
||||
entry_b = _ApprovalEntry(approval_b)
|
||||
captured = {}
|
||||
|
||||
def fake_j(handler, data, status=200, extra_headers=None):
|
||||
captured["payload"] = data
|
||||
captured["status"] = status
|
||||
return data
|
||||
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
monkeypatch.setattr(r, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None))
|
||||
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues[sid] = [entry_a, entry_b]
|
||||
try:
|
||||
ra.submit_gateway_pending_mirror(sid, approval_a)
|
||||
ra.submit_gateway_pending_mirror(sid, approval_b)
|
||||
with _lock:
|
||||
approval_b_id = next(
|
||||
item["approval_id"] for item in r._pending[sid]
|
||||
if item["command"] == approval_b["command"]
|
||||
)
|
||||
r._handle_approval_respond(
|
||||
object(), {"session_id": sid, "choice": "once", "approval_id": approval_b_id}
|
||||
)
|
||||
assert captured["status"] == 200
|
||||
assert entry_b.event.is_set()
|
||||
assert entry_b.result == "once"
|
||||
assert not entry_a.event.is_set()
|
||||
pending = r._handle_approval_pending(
|
||||
object(), urllib.parse.urlparse(
|
||||
f"/api/approval/pending?session_id={urllib.parse.quote(sid)}"
|
||||
)
|
||||
)
|
||||
assert pending["pending"]["command"] == approval_a["command"]
|
||||
assert pending["pending_count"] == 1
|
||||
finally:
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, None)
|
||||
@@ -135,6 +135,10 @@ class TestCancelStreamOwnerGuardStructural:
|
||||
"""When the backend reports ``cancelled:false``, the fix should
|
||||
show a lightweight toast (the backend may have already finalized
|
||||
the turn, or a newer turn may hold the session lock)."""
|
||||
assert "r.ok" in CANCEL_STREAM_SRC, (
|
||||
"cancelStream() must gate cancelled:false cleanup on an HTTP-success "
|
||||
"response so failed gateway stops do not settle the UI locally"
|
||||
)
|
||||
assert "cancelled" in CANCEL_STREAM_SRC and "false" in CANCEL_STREAM_SRC, (
|
||||
"cancelStream() must check respBody.cancelled === false and surface "
|
||||
"a toast (e.g. 'Stream is no longer active')"
|
||||
@@ -254,6 +258,25 @@ async function runAll() {
|
||||
toastCalls: [...M.toastCalls],
|
||||
};
|
||||
|
||||
// T3b — failed HTTP response with cancelled:false body: keep the active
|
||||
// owner path intact because the cancel did not succeed.
|
||||
reset();
|
||||
globalThis.S = { activeStreamId: 'stream-1', session: { session_id: 'sid-1' } };
|
||||
_fetchResponse = {
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ ok: false, cancelled: false, stream_id: 'stream-1' }),
|
||||
};
|
||||
_fetchThrows = false;
|
||||
await cancelStream();
|
||||
out.t3b_failed_http = {
|
||||
finalActiveStreamId: globalThis.S.activeStreamId,
|
||||
fetchCalls: M.fetchCalls.length,
|
||||
closeCalls: [...M.closeCalls],
|
||||
busyCalls: [...M.busyCalls],
|
||||
composerCalls: [...M.composerCalls],
|
||||
toastCalls: [...M.toastCalls],
|
||||
};
|
||||
|
||||
// T4 — network error: no throw, active owner kept because we do not know
|
||||
// whether the cancel landed.
|
||||
reset();
|
||||
@@ -466,6 +489,26 @@ class TestCancelStreamOwnerGuardRuntime:
|
||||
f"got {r['toastCalls']}"
|
||||
)
|
||||
|
||||
def test_t3b_failed_http_keeps_owner_state(self, runtime_results):
|
||||
r = runtime_results["t3b_failed_http"]
|
||||
assert r["fetchCalls"] == 1, (
|
||||
f"cancelStream() must still issue the cancel request on failed HTTP, "
|
||||
f"got {r['fetchCalls']}"
|
||||
)
|
||||
assert r["finalActiveStreamId"] == "stream-1", (
|
||||
f"cancelStream() must keep S.activeStreamId when /api/chat/cancel "
|
||||
f"fails, got {r['finalActiveStreamId']!r}"
|
||||
)
|
||||
assert r["closeCalls"] == [], (
|
||||
f"cancelStream() must keep owned SSE open on failed HTTP, got {r['closeCalls']}"
|
||||
)
|
||||
assert r["busyCalls"] == [], (
|
||||
f"cancelStream() must not call setBusy(false) on failed HTTP, got {r['busyCalls']}"
|
||||
)
|
||||
assert r["toastCalls"] == [], (
|
||||
f"cancelStream() should not show a toast on failed HTTP, got {r['toastCalls']}"
|
||||
)
|
||||
|
||||
def test_t5_owner_guard_preserves_new_turn(self, runtime_results):
|
||||
r = runtime_results["t5_owner_guard"]
|
||||
# The fetch still happened for the OLD stream.
|
||||
@@ -512,12 +555,14 @@ class TestCancelStreamOwnerGuardRuntime:
|
||||
)
|
||||
assert runtime_results["t3_cancelled_false"]["finalActiveStreamId"] is None
|
||||
assert runtime_results["t3_cancelled_false"]["busyCalls"] == [False]
|
||||
assert runtime_results["t3b_failed_http"]["finalActiveStreamId"] == "stream-1"
|
||||
assert runtime_results["t3b_failed_http"]["busyCalls"] == []
|
||||
# T5 is the owner-rotation case and must preserve the new turn.
|
||||
assert runtime_results["t5_owner_guard"]["finalActiveStreamId"] == "stream-2"
|
||||
assert runtime_results["t5_owner_guard"]["busyCalls"] == []
|
||||
# Only the stale-owner path closes the old SSE; active owned paths
|
||||
# keep it open so the backend terminal event can settle/render.
|
||||
for key in ("t2_happy_path", "t3_cancelled_false", "t4_network_error", "t5_owner_guard"):
|
||||
for key in ("t2_happy_path", "t3_cancelled_false", "t3b_failed_http", "t4_network_error", "t5_owner_guard"):
|
||||
if key == "t5_owner_guard":
|
||||
assert runtime_results[key]["closeCalls"] == [["sid-1", "stream-1"]], (
|
||||
f"{key} must close stale old SSE for ('sid-1', 'stream-1'), "
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -7,6 +7,8 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from api.streaming import (
|
||||
_compact_image_parts_for_persistence,
|
||||
_compact_session_image_parts_for_persistence,
|
||||
_restore_reasoning_metadata,
|
||||
_sanitize_messages_for_api,
|
||||
_strip_oob_blocks,
|
||||
@@ -24,6 +26,171 @@ def _contains_oob(value) -> bool:
|
||||
return "OUT-OF-BAND USER MESSAGE" in json.dumps(value)
|
||||
|
||||
|
||||
|
||||
def test_compact_image_parts_for_persistence_keeps_text_and_drops_base64():
|
||||
image_data_url = "data:image/png;base64," + ("A" * 1_000_000)
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "vision-1"}],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "vision-1",
|
||||
"content": [
|
||||
{"type": "text", "text": "Image loaded into your context."},
|
||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
changed = _compact_image_parts_for_persistence(messages)
|
||||
|
||||
assert changed == 1
|
||||
assert messages[1]["content"] == [
|
||||
{"type": "text", "text": "Image loaded into your context."},
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
]
|
||||
assert "data:image" not in json.dumps(messages)
|
||||
assert messages[0]["tool_calls"] == [{"id": "vision-1"}]
|
||||
|
||||
|
||||
|
||||
|
||||
def test_compact_image_parts_for_persistence_counts_each_image_part():
|
||||
messages = [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{"type": "text", "text": "Before and after"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
{"type": "input_image", "image_url": {"url": "data:image/png;base64,BBBB"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
changed = _compact_image_parts_for_persistence(messages)
|
||||
|
||||
assert changed == 2
|
||||
assert messages[0]["content"] == [
|
||||
{"type": "text", "text": "Before and after"},
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
]
|
||||
|
||||
|
||||
def test_compact_image_parts_for_persistence_preserves_interleaved_non_image_parts():
|
||||
document = {"type": "document", "document": {"name": "report.pdf", "id": "doc-1"}}
|
||||
scalar = "provider extension payload"
|
||||
messages = [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
{"type": "text", "text": "between"},
|
||||
document,
|
||||
scalar,
|
||||
{"type": "input_image", "image_url": {"url": "data:image/png;base64,BBBB"}},
|
||||
{"type": "input_text", "content": "after"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
changed = _compact_image_parts_for_persistence(messages)
|
||||
|
||||
assert changed == 2
|
||||
assert messages[0]["content"] == [
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
{"type": "text", "text": "between"},
|
||||
document,
|
||||
scalar,
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
{"type": "input_text", "content": "after"},
|
||||
]
|
||||
assert "data:image" not in json.dumps(messages)
|
||||
assert _compact_image_parts_for_persistence(messages) == 0
|
||||
|
||||
|
||||
def test_compact_image_parts_for_persistence_survives_unhashable_part_type():
|
||||
# A JSON-valid part can carry a non-string (unhashable) ``type`` such as a
|
||||
# list or dict. ``part.get("type") in {...}`` would raise TypeError on an
|
||||
# unhashable value, turning an otherwise-complete streaming send into the
|
||||
# error path before the session is saved. Such parts must be preserved
|
||||
# unchanged, and real image parts alongside them must still compact.
|
||||
list_type = {"type": ["provider-extension"], "data": "x"}
|
||||
dict_type = {"type": {"nested": "kind"}, "data": "y"}
|
||||
messages = [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
list_type,
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
dict_type,
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
changed = _compact_image_parts_for_persistence(messages)
|
||||
|
||||
assert changed == 1
|
||||
assert messages[0]["content"] == [
|
||||
list_type,
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
dict_type,
|
||||
]
|
||||
assert "data:image" not in json.dumps(messages)
|
||||
# Idempotent: a second pass changes nothing and still does not raise.
|
||||
assert _compact_image_parts_for_persistence(messages) == 0
|
||||
|
||||
|
||||
def test_compact_session_image_parts_for_persistence_compacts_both_histories():
|
||||
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64," + ("A" * 4096)}}
|
||||
session = SimpleNamespace(
|
||||
session_id="vision-turn",
|
||||
messages=[{"role": "tool", "content": [{"type": "text", "text": "display"}, image.copy()]}],
|
||||
context_messages=[{"role": "tool", "content": [{"type": "text", "text": "context"}, image.copy()]}],
|
||||
)
|
||||
|
||||
changed = _compact_session_image_parts_for_persistence(session)
|
||||
|
||||
assert changed == 2
|
||||
assert session.messages[0]["content"] == [
|
||||
{"type": "text", "text": "display"},
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
]
|
||||
assert session.context_messages[0]["content"] == [
|
||||
{"type": "text", "text": "context"},
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
]
|
||||
|
||||
|
||||
def test_compact_image_parts_for_persistence_keeps_user_image_attachment():
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Inspect this attachment"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
changed = _compact_image_parts_for_persistence(messages)
|
||||
|
||||
assert changed == 0
|
||||
assert messages[0]["content"][1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_compact_image_parts_for_persistence_leaves_text_history_unchanged():
|
||||
messages = [{"role": "tool", "content": "plain tool output"}]
|
||||
|
||||
changed = _compact_image_parts_for_persistence(messages)
|
||||
|
||||
assert changed == 0
|
||||
assert messages == [{"role": "tool", "content": "plain tool output"}]
|
||||
|
||||
|
||||
def test_strip_oob_blocks_string():
|
||||
content = f"visible before\n{OOB_BLOCK}\nvisible after"
|
||||
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
"""ctl.sh must not double-start against a foreign/supervised WebUI instance.
|
||||
|
||||
Field failure this pins down: a systemd-supervised WebUI (Restart=always) was
|
||||
serving :8787 while ctl.sh's PID file was stale. `ctl.sh stop` said "stopped",
|
||||
`ctl.sh restart` spawned a bootstrap that died ~2s later on server.py's
|
||||
"already responding" startup check — AFTER ctl.sh's old 0.15s aliveness gate
|
||||
had printed "Started" and recorded the doomed PID. Killing the foreign server
|
||||
by hand then put systemd's auto-restart into a race with ctl.sh's own start,
|
||||
ending in a permanent 5s crash loop (each systemd attempt aborting against the
|
||||
ctl.sh-started server).
|
||||
|
||||
Guards under test:
|
||||
- start refuses when anything already answers HTTP(S) on the target port.
|
||||
- start refuses when the hermes-webui systemd unit is active on our port or
|
||||
mid-auto-restart (activating), instead of racing its next respawn.
|
||||
- start reports failure (and cleans the PID file) when the spawned server dies
|
||||
during the startup window instead of printing success after 0.15s.
|
||||
- status/stop surface a running unmanaged instance instead of claiming
|
||||
"stopped" and never touch a process ctl.sh does not own.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_ctl_script import (
|
||||
bash_path,
|
||||
run_ctl,
|
||||
write_fake_python,
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _start_dummy_http_server(port: int, status: int = 200) -> subprocess.Popen:
|
||||
"""A minimal HTTP server answering every request with `status`."""
|
||||
code = textwrap.dedent(
|
||||
f"""
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response({status})
|
||||
body = b'{{"status": "ok", "sessions": 0, "active_streams": 0}}'
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
HTTPServer(("127.0.0.1", {port}), H).serve_forever()
|
||||
"""
|
||||
)
|
||||
proc = subprocess.Popen([sys.executable, "-c", code])
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return proc
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
proc.terminate()
|
||||
raise AssertionError("dummy HTTP server did not come up")
|
||||
|
||||
|
||||
def _stop_proc(proc: subprocess.Popen) -> None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def _write_fake_systemctl(
|
||||
fake_bin: Path,
|
||||
active_state: str,
|
||||
main_pid: int = 0,
|
||||
environment: str = "",
|
||||
exec_start: str = "",
|
||||
) -> None:
|
||||
"""Fake systemctl answering `show -p <prop> --value <unit>` for the
|
||||
ActiveState/MainPID/Environment/ExecStart properties the guard inspects."""
|
||||
systemctl = fake_bin / "systemctl"
|
||||
systemctl.write_text(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
#!/usr/bin/env bash
|
||||
for arg in "$@"; do
|
||||
case "${{arg}}" in
|
||||
*ActiveState*) echo "{active_state}"; exit 0 ;;
|
||||
*MainPID*) echo "{main_pid}"; exit 0 ;;
|
||||
*Environment*) echo "{environment}"; exit 0 ;;
|
||||
*ExecStart*) echo "{exec_start}"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
"""
|
||||
).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
systemctl.chmod(0o755)
|
||||
|
||||
|
||||
def _write_fake_lsof_with_real_and_semantics(fake_bin: Path) -> None:
|
||||
"""Fake lsof mimicking real -a semantics: without -a, `-p PID -iTCP:PORT`
|
||||
OR-combines its selectors (matching whenever EITHER hits); with -a they
|
||||
AND. Exit codes come from FAKE_LSOF_OR_EXIT / FAKE_LSOF_AND_EXIT so a test
|
||||
can model 'the pid is alive with sockets, but nothing of it listens on the
|
||||
requested port' — the case a missing -a falsely reports as a conflict."""
|
||||
lsof = fake_bin / "lsof"
|
||||
lsof.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
#!/usr/bin/env bash
|
||||
has_a=0
|
||||
for arg in "$@"; do
|
||||
[[ "${arg}" == "-a" ]] && has_a=1
|
||||
done
|
||||
if [[ "${has_a}" == 1 ]]; then
|
||||
exit "${FAKE_LSOF_AND_EXIT:-1}"
|
||||
fi
|
||||
exit "${FAKE_LSOF_OR_EXIT:-0}"
|
||||
"""
|
||||
).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
lsof.chmod(0o755)
|
||||
|
||||
|
||||
def _write_fake_port_tools(fake_bin: Path, pid_listens: bool) -> None:
|
||||
"""Fake lsof/ss so _pid_listens_on_port resolves deterministically."""
|
||||
lsof = fake_bin / "lsof"
|
||||
lsof.write_text(
|
||||
"#!/usr/bin/env bash\n" + ("exit 0\n" if pid_listens else "exit 1\n"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
lsof.chmod(0o755)
|
||||
# _pid_listens_on_port prefers lsof; keep a fake ss anyway so the shim
|
||||
# works on hosts without lsof, where the ss branch runs instead.
|
||||
ss = fake_bin / "ss"
|
||||
if pid_listens:
|
||||
ss.write_text(
|
||||
'#!/usr/bin/env bash\n'
|
||||
'echo "LISTEN 0 64 0.0.0.0:8787 0.0.0.0:* users:((\\"python\\",pid=4242,fd=7))"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
ss.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||
ss.chmod(0o755)
|
||||
|
||||
|
||||
def _guard_env(fake_bin: Path | None = None, **extra: str) -> dict[str, str]:
|
||||
env = {
|
||||
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
|
||||
}
|
||||
if fake_bin is not None:
|
||||
env["PATH"] = f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_refuses_when_port_already_serving(tmp_path):
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 2, combined
|
||||
assert "already responding" in combined
|
||||
assert not (tmp_path / ".hermes" / "webui.pid").exists()
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_refuses_even_when_squatter_answers_http_errors(tmp_path):
|
||||
"""server.py treats ANY response bytes as a conflict; the guard must match
|
||||
(a 404 from a foreign app squatting the port still dooms our server)."""
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port, status=404)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 2, combined
|
||||
assert "already responding" in combined
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="systemd is a Linux/POSIX path")
|
||||
def test_start_refuses_when_systemd_unit_is_auto_restarting(tmp_path):
|
||||
"""ActiveState=activating means the unit is between Restart= attempts: the
|
||||
port is briefly silent, but starting now traps the unit in a crash loop."""
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_systemctl(fake_bin, "activating")
|
||||
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(fake_bin),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 2, combined
|
||||
assert "activating" in combined
|
||||
assert "systemctl" in combined
|
||||
assert not (tmp_path / ".hermes" / "webui.pid").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="systemd is a Linux/POSIX path")
|
||||
def test_start_refuses_when_systemd_unit_active_on_our_port(tmp_path):
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_systemctl(fake_bin, "active", main_pid=4242)
|
||||
_write_fake_port_tools(fake_bin, pid_listens=True)
|
||||
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(fake_bin),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 2, combined
|
||||
assert "4242" in combined
|
||||
assert not (tmp_path / ".hermes" / "webui.pid").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="systemd is a Linux/POSIX path")
|
||||
def test_start_allows_alternate_port_while_systemd_unit_auto_restarts(tmp_path):
|
||||
"""Mirror of the launchd #3291 over-block fix: an auto-restarting default
|
||||
unit must not block a test instance on a different port."""
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_systemctl(fake_bin, "activating")
|
||||
|
||||
fake_python = tmp_path / "fake-python"
|
||||
fake_log = tmp_path / "fake-python.log"
|
||||
write_fake_python(fake_python)
|
||||
|
||||
port = _free_port()
|
||||
started_pid = None
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
fake_bin,
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_PYTHON=str(fake_python),
|
||||
FAKE_PYTHON_LOG=str(fake_log),
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Refusing to start" not in combined, combined
|
||||
assert result.returncode == 0, combined
|
||||
pid_file = tmp_path / ".hermes" / "webui.pid"
|
||||
if pid_file.exists():
|
||||
started_pid = int(pid_file.read_text().strip())
|
||||
finally:
|
||||
if started_pid:
|
||||
try:
|
||||
os.kill(started_pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_systemd_pid_port_check_requires_and_semantics(tmp_path):
|
||||
"""lsof without -a OR-combines -p/-i, so an active unit whose MainPID has
|
||||
sockets but does NOT listen on the requested port must not be mistaken
|
||||
for a conflict (12.07 re-gate finding 2)."""
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_systemctl(fake_bin, "active", main_pid=4242)
|
||||
_write_fake_lsof_with_real_and_semantics(fake_bin)
|
||||
|
||||
fake_python = tmp_path / "fake-python"
|
||||
fake_log = tmp_path / "fake-python.log"
|
||||
write_fake_python(fake_python)
|
||||
|
||||
port = _free_port()
|
||||
started_pid = None
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
fake_bin,
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_PYTHON=str(fake_python),
|
||||
FAKE_PYTHON_LOG=str(fake_log),
|
||||
FAKE_LSOF_OR_EXIT="0",
|
||||
FAKE_LSOF_AND_EXIT="1",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Refusing to start" not in combined, combined
|
||||
assert result.returncode == 0, combined
|
||||
pid_file = tmp_path / ".hermes" / "webui.pid"
|
||||
if pid_file.exists():
|
||||
started_pid = int(pid_file.read_text().strip())
|
||||
finally:
|
||||
if started_pid:
|
||||
try:
|
||||
os.kill(started_pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_on_default_port_allowed_when_unit_configured_elsewhere(tmp_path):
|
||||
"""Reverse alternate-port case (12.07 re-gate finding 3): a unit whose
|
||||
CONFIGURED binding is a non-default port must not block a start on 8787
|
||||
just because its ownership cannot be attributed via MainPID."""
|
||||
with socket.socket() as probe:
|
||||
if probe.connect_ex(("127.0.0.1", 8787)) == 0:
|
||||
pytest.skip("default port 8787 already occupied on this host")
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_systemctl(
|
||||
fake_bin, "activating", environment="HERMES_WEBUI_PORT=9999"
|
||||
)
|
||||
|
||||
fake_python = tmp_path / "fake-python"
|
||||
fake_log = tmp_path / "fake-python.log"
|
||||
write_fake_python(fake_python)
|
||||
|
||||
started_pid = None
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
fake_bin,
|
||||
HERMES_WEBUI_PORT="8787",
|
||||
HERMES_WEBUI_PYTHON=str(fake_python),
|
||||
FAKE_PYTHON_LOG=str(fake_log),
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Refusing to start" not in combined, combined
|
||||
assert result.returncode == 0, combined
|
||||
pid_file = tmp_path / ".hermes" / "webui.pid"
|
||||
if pid_file.exists():
|
||||
started_pid = int(pid_file.read_text().strip())
|
||||
finally:
|
||||
if started_pid:
|
||||
try:
|
||||
os.kill(started_pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_refuses_when_unit_configured_for_requested_alternate_port(tmp_path):
|
||||
"""Counterpart of the reverse case: when the unit's configured binding
|
||||
matches the requested (non-default) port, the conflict must be reported."""
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
port = _free_port()
|
||||
_write_fake_systemctl(
|
||||
fake_bin, "activating", environment=f"HERMES_WEBUI_PORT={port}"
|
||||
)
|
||||
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(fake_bin, HERMES_WEBUI_PORT=str(port)),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Refusing to start" in combined, combined
|
||||
assert result.returncode != 0, combined
|
||||
assert str(port) in combined
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
@pytest.mark.parametrize(
|
||||
("command", "expected_output"),
|
||||
[
|
||||
("status", "running (not managed by ctl.sh)"),
|
||||
("stop", "NOT managed by ctl.sh"),
|
||||
],
|
||||
)
|
||||
def test_unmanaged_instance_commands_survive_inherit_errexit(
|
||||
tmp_path, command, expected_output
|
||||
):
|
||||
"""12.07 re-gate finding 1: with errexit inherited into command
|
||||
substitutions (BASHOPTS=inherit_errexit from the invoking shell), the
|
||||
no-match listener diagnostics must still not abort status/stop."""
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_port_tools(fake_bin, pid_listens=False)
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
command,
|
||||
env=_guard_env(
|
||||
fake_bin,
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
BASHOPTS="inherit_errexit",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 0, combined
|
||||
assert expected_output in combined
|
||||
assert server.poll() is None, "ctl.sh must leave a foreign server untouched"
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_proceeds_when_systemd_unit_inactive(tmp_path):
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_systemctl(fake_bin, "inactive")
|
||||
|
||||
fake_python = tmp_path / "fake-python"
|
||||
fake_log = tmp_path / "fake-python.log"
|
||||
write_fake_python(fake_python)
|
||||
|
||||
port = _free_port()
|
||||
started_pid = None
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
fake_bin,
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_PYTHON=str(fake_python),
|
||||
FAKE_PYTHON_LOG=str(fake_log),
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert "Refusing to start" not in combined, combined
|
||||
assert result.returncode == 0, combined
|
||||
started_pid = int((tmp_path / ".hermes" / "webui.pid").read_text().strip())
|
||||
finally:
|
||||
if started_pid:
|
||||
try:
|
||||
os.kill(started_pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_reports_failure_when_server_dies_during_startup(tmp_path):
|
||||
"""A server that exits ~0.5s in (import error, stolen port) must yield a
|
||||
failure and no stale PID file — not '[ctl] Started'."""
|
||||
dying_python = tmp_path / "dying-python"
|
||||
dying_python.write_text(
|
||||
"#!/usr/bin/env bash\nsleep 0.5\nexit 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
dying_python.chmod(0o755)
|
||||
|
||||
port = _free_port()
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_PYTHON=str(dying_python),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 1, combined
|
||||
assert "failed to stay running" in combined
|
||||
assert "Started Hermes WebUI" not in combined
|
||||
assert not (tmp_path / ".hermes" / "webui.pid").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_status_reports_unmanaged_running_instance(tmp_path):
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"status",
|
||||
env=_guard_env(HERMES_WEBUI_PORT=str(port)),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 0, combined
|
||||
assert "running (not managed by ctl.sh)" in combined
|
||||
assert "stopped" not in combined
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
@pytest.mark.parametrize(
|
||||
("command", "expected_output"),
|
||||
[
|
||||
("status", "running (not managed by ctl.sh)"),
|
||||
("stop", "NOT managed by ctl.sh"),
|
||||
],
|
||||
)
|
||||
def test_unmanaged_instance_commands_survive_missing_listener_diagnostics(
|
||||
tmp_path, command, expected_output
|
||||
):
|
||||
"""Best-effort listener diagnostics must not abort status/stop under set -e
|
||||
when ss/lsof are unavailable or return no matching listener row."""
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
_write_fake_port_tools(fake_bin, pid_listens=False)
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
command,
|
||||
env=_guard_env(fake_bin, HERMES_WEBUI_PORT=str(port)),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 0, combined
|
||||
assert expected_output in combined
|
||||
assert "Listener:" not in combined
|
||||
assert server.poll() is None, "ctl.sh must leave a foreign server untouched"
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_stop_warns_about_unmanaged_instance_and_leaves_it_alone(tmp_path):
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"stop",
|
||||
env=_guard_env(HERMES_WEBUI_PORT=str(port)),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 0, combined
|
||||
assert "NOT managed by ctl.sh" in combined
|
||||
assert server.poll() is None, "ctl.sh stop must not kill a foreign server"
|
||||
# The foreign server must still answer after stop returned.
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
pass
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_port_guard_ignores_http_proxy_env(tmp_path):
|
||||
"""The ownership probe must force a direct connection: a configured
|
||||
http_proxy would report the proxy, not the local port (Greptile P1 on
|
||||
#5944) — a dead proxy hides an occupied port and start proceeds doomed."""
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
# Dead proxy: any probe routed through it sees no responder.
|
||||
http_proxy="http://127.0.0.1:1",
|
||||
https_proxy="http://127.0.0.1:1",
|
||||
HTTP_PROXY="http://127.0.0.1:1",
|
||||
HTTPS_PROXY="http://127.0.0.1:1",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 2, combined
|
||||
assert "already responding" in combined
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_start_health_probe_ignores_all_proxy_env(tmp_path):
|
||||
"""The startup watch must reach its own local health server directly.
|
||||
|
||||
Unlike the preflight ownership guard above, this drives the post-launch
|
||||
health loop: a dead ALL_PROXY must not make ctl wait out its grace period
|
||||
and print the misleading health-timeout note.
|
||||
"""
|
||||
port = _free_port()
|
||||
health_server = tmp_path / "health-server"
|
||||
health_server.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class HealthHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = b'{"status": "ok"}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
HTTPServer(("127.0.0.1", int(sys.argv[-1])), HealthHandler).serve_forever()
|
||||
"""
|
||||
).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
health_server.chmod(0o755)
|
||||
|
||||
started_pid = None
|
||||
try:
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_PYTHON=str(health_server),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
# A live local server must still win when both proxy spellings
|
||||
# route clients to a dead endpoint.
|
||||
ALL_PROXY="http://127.0.0.1:1",
|
||||
all_proxy="http://127.0.0.1:1",
|
||||
NO_PROXY="",
|
||||
no_proxy="",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
pid_file = tmp_path / ".hermes" / "webui.pid"
|
||||
if pid_file.exists():
|
||||
started_pid = int(pid_file.read_text().strip())
|
||||
assert result.returncode == 0, combined
|
||||
assert "/health did not respond" not in combined
|
||||
finally:
|
||||
if started_pid:
|
||||
try:
|
||||
os.kill(started_pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_ipv6_host_probe_builds_bracketed_url(tmp_path):
|
||||
"""HERMES_WEBUI_HOST='::1' must probe http://[::1]:port — the unbracketed
|
||||
literal is rejected by curl/wget and the running instance is missed."""
|
||||
if not socket.has_ipv6:
|
||||
pytest.skip("no IPv6 support")
|
||||
port = _free_port()
|
||||
code = textwrap.dedent(
|
||||
f"""
|
||||
import socket
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class V6Server(HTTPServer):
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = b'{{"status": "ok"}}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
V6Server(("::1", {port}), H).serve_forever()
|
||||
"""
|
||||
)
|
||||
try:
|
||||
server = subprocess.Popen([sys.executable, "-c", code])
|
||||
except OSError:
|
||||
pytest.skip("cannot bind ::1")
|
||||
try:
|
||||
deadline = time.time() + 5
|
||||
up = False
|
||||
while time.time() < deadline:
|
||||
if server.poll() is not None:
|
||||
pytest.skip("IPv6 loopback unavailable")
|
||||
try:
|
||||
with socket.create_connection(("::1", port), timeout=0.2):
|
||||
up = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
assert up, "IPv6 dummy server did not come up"
|
||||
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_HOST="::1",
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 2, combined
|
||||
assert "already responding" in combined
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_zero_start_grace_still_monitors_startup(tmp_path):
|
||||
"""HERMES_WEBUI_START_GRACE=0 must not disable the startup watch — that
|
||||
would restore the exact stale-PID-on-early-death behavior being fixed."""
|
||||
dying_python = tmp_path / "dying-python"
|
||||
dying_python.write_text(
|
||||
"#!/usr/bin/env bash\nsleep 0.5\nexit 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
dying_python.chmod(0o755)
|
||||
|
||||
port = _free_port()
|
||||
result = run_ctl(
|
||||
tmp_path,
|
||||
"start",
|
||||
env=_guard_env(
|
||||
HERMES_WEBUI_PORT=str(port),
|
||||
HERMES_WEBUI_PYTHON=str(dying_python),
|
||||
HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1",
|
||||
HERMES_WEBUI_START_GRACE="0",
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 1, combined
|
||||
assert "failed to stay running" in combined
|
||||
assert not (tmp_path / ".hermes" / "webui.pid").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards")
|
||||
def test_stop_warns_using_saved_binding_from_state_file(tmp_path):
|
||||
"""With no PID file but a saved off-default binding in the state file,
|
||||
stop must probe THAT binding (before deleting the file) and still warn
|
||||
about the unmanaged instance."""
|
||||
port = _free_port()
|
||||
server = _start_dummy_http_server(port)
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "webui.ctl.env").write_text(
|
||||
f"PID=999999\nHOST=127.0.0.1\nPORT={port}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
# No HERMES_WEBUI_PORT in the environment: the probe target must come
|
||||
# from the saved state file.
|
||||
result = run_ctl(tmp_path, "stop", env=_guard_env(), timeout=15)
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 0, combined
|
||||
assert "NOT managed by ctl.sh" in combined
|
||||
assert server.poll() is None
|
||||
finally:
|
||||
_stop_proc(server)
|
||||
@@ -27,7 +27,7 @@ def run_ctl(
|
||||
home: Path,
|
||||
*args: str,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = 5.0,
|
||||
timeout: float = 15.0,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
load_dotenv: bool = False,
|
||||
):
|
||||
@@ -269,6 +269,11 @@ def test_start_can_ignore_repo_dotenv_for_authoritative_test_env(tmp_path):
|
||||
"HERMES_WEBUI_PYTHON": str(fake_python),
|
||||
"FAKE_PYTHON_LOG": str(fake_log),
|
||||
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
|
||||
# This test exercises dotenv precedence on the DEFAULT port; keep
|
||||
# it hermetic on developer machines where a real WebUI (systemd
|
||||
# unit or manual run) is serving 8787.
|
||||
"HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT": "1",
|
||||
"HERMES_WEBUI_CTL_ALLOW_PORT_CONFLICT": "1",
|
||||
},
|
||||
repo_root=repo_root,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -135,6 +135,8 @@ def test_extension_sidecar_proxy_requires_consent_and_reconfirms_after_origin_ch
|
||||
"origin": "http://127.0.0.1:17787",
|
||||
"proxy_path": "/api/extensions/templates/sidecar/",
|
||||
"upstream_url": "http://127.0.0.1:17787/v1/ping?debug=1",
|
||||
"proxy_auth": "legacy",
|
||||
"auth_token": None,
|
||||
}
|
||||
|
||||
encoded_target = resolve_extension_sidecar_proxy_target("templates", "v1%2Fprivate")
|
||||
@@ -963,3 +965,285 @@ def test_extension_sidecar_proxy_consent_route_is_wired(monkeypatch):
|
||||
"status": 200,
|
||||
"data": {"ok": True, "id": "templates", "approved": True},
|
||||
}
|
||||
|
||||
|
||||
def test_extension_sidecar_proxy_consent_route_fails_closed_auth_off(tmp_path, monkeypatch):
|
||||
# Frank #6331 blocker 1 (route-level regression): with WebUI auth OFF, driving
|
||||
# the REAL consent function through the HTTP route for a token-v1 sidecar must
|
||||
# return 403 (fail closed), not persist consent. Uses the real
|
||||
# set_extension_sidecar_proxy_consent (NOT a mock) so the route + auth gate are
|
||||
# exercised end-to-end.
|
||||
from api import routes
|
||||
|
||||
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
|
||||
from api.auth import _invalidate_password_hash_cache, is_auth_enabled
|
||||
_invalidate_password_hash_cache()
|
||||
assert is_auth_enabled() is False
|
||||
_token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_bad(handler, msg, status=400):
|
||||
captured["status"] = status
|
||||
captured["msg"] = msg
|
||||
return True
|
||||
|
||||
def fake_j(handler, data, status=200, headers=None):
|
||||
captured["status"] = status
|
||||
captured["data"] = data
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(routes, "read_body", lambda handler: {"id": "templates", "approved": True})
|
||||
monkeypatch.setattr(routes, "j", fake_j)
|
||||
monkeypatch.setattr(routes, "bad", fake_bad)
|
||||
handler = FakeHandler()
|
||||
|
||||
assert routes.handle_post(
|
||||
handler,
|
||||
SimpleNamespace(path="/api/extensions/sidecar-proxy-consent"),
|
||||
) is True
|
||||
# Must be a fail-closed 403 from the real consent function, not a 200 grant.
|
||||
assert captured.get("status") == 403
|
||||
assert "data" not in captured
|
||||
# And no consent must have been persisted.
|
||||
from api.extensions import resolve_extension_sidecar_proxy_target, ExtensionSidecarProxyError
|
||||
with pytest.raises(ExtensionSidecarProxyError) as exc:
|
||||
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
||||
assert exc.value.status == 403
|
||||
|
||||
|
||||
# ── token-v1 proxy auth (§9.1/§9.2) ─────────────────────────────────────────
|
||||
|
||||
def _token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787"):
|
||||
return _configure_manifest_extension(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "templates",
|
||||
"sidecar": {
|
||||
"type": "loopback",
|
||||
"origin": origin,
|
||||
"proxy_auth": "token-v1",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_token_v1_injects_persisted_token_when_auth_enabled(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pw")
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
_token_v1_manifest(monkeypatch, tmp_path)
|
||||
from api.extensions import (
|
||||
resolve_extension_sidecar_proxy_target,
|
||||
set_extension_sidecar_proxy_consent,
|
||||
)
|
||||
import api.extension_sidecar_auth as sc
|
||||
|
||||
set_extension_sidecar_proxy_consent("templates", True)
|
||||
target = resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
||||
assert target["proxy_auth"] == "token-v1"
|
||||
assert target["auth_token"] and len(target["auth_token"]) > 30
|
||||
assert target["auth_token"] == sc.current_token("templates")
|
||||
|
||||
|
||||
def test_token_v1_auth_off_blocks_consent_and_resolution_fail_closed(tmp_path, monkeypatch):
|
||||
# Frank #6331 blocker 1: with WebUI auth OFF, token-v1 must fail closed at BOTH
|
||||
# the consent grant and the resolution path — regardless of a loopback origin.
|
||||
# A loopback sidecar is NOT a sufficient guard: any caller that can reach the
|
||||
# (unauthenticated) WebUI listener could otherwise self-grant consent and drive
|
||||
# the token-bearing proxy (a forwarding oracle); another local UID can reach the
|
||||
# listener without ever reading the 0600 token file.
|
||||
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
|
||||
from api.auth import _invalidate_password_hash_cache, is_auth_enabled
|
||||
_invalidate_password_hash_cache()
|
||||
assert is_auth_enabled() is False
|
||||
_token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787")
|
||||
from api.extensions import (
|
||||
ExtensionSidecarProxyError,
|
||||
set_extension_sidecar_proxy_consent,
|
||||
)
|
||||
|
||||
# Consent grant is rejected fail-closed (403) even for a loopback origin.
|
||||
with pytest.raises(ExtensionSidecarProxyError) as consent_exc:
|
||||
set_extension_sidecar_proxy_consent("templates", True)
|
||||
assert consent_exc.value.status == 403
|
||||
|
||||
|
||||
def test_token_v1_resolution_fails_closed_when_auth_disabled_after_consent(tmp_path, monkeypatch):
|
||||
# Defense in depth: even if a consent record somehow exists (e.g. granted while
|
||||
# auth was enabled, then auth turned off), the resolution path must ALSO fail
|
||||
# closed so a stale consent can't be exercised without WebUI auth.
|
||||
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pw")
|
||||
from api.auth import _invalidate_password_hash_cache, is_auth_enabled
|
||||
_invalidate_password_hash_cache()
|
||||
_token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787")
|
||||
from api.extensions import (
|
||||
ExtensionSidecarProxyError,
|
||||
resolve_extension_sidecar_proxy_target,
|
||||
set_extension_sidecar_proxy_consent,
|
||||
)
|
||||
|
||||
# Grant consent WHILE auth is enabled (allowed).
|
||||
assert is_auth_enabled() is True
|
||||
set_extension_sidecar_proxy_consent("templates", True)
|
||||
# Now disable auth and confirm resolution refuses the pre-existing consent.
|
||||
monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False)
|
||||
_invalidate_password_hash_cache()
|
||||
assert is_auth_enabled() is False
|
||||
with pytest.raises(ExtensionSidecarProxyError) as exc:
|
||||
resolve_extension_sidecar_proxy_target("templates", "v1/ping")
|
||||
assert exc.value.status == 403
|
||||
|
||||
|
||||
def test_unknown_proxy_auth_value_rejects_sidecar(tmp_path, monkeypatch):
|
||||
_configure_manifest_extension(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "templates",
|
||||
"sidecar": {
|
||||
"type": "loopback",
|
||||
"origin": "http://127.0.0.1:17787",
|
||||
"proxy_auth": "totally-bogus",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
from api.extensions import (
|
||||
ExtensionSidecarProxyError,
|
||||
set_extension_sidecar_proxy_consent,
|
||||
)
|
||||
|
||||
# Unknown proxy_auth -> _sidecar_from_manifest_entry rejects the record, so the
|
||||
# sidecar never becomes available/consentable (fail closed, not silent-open).
|
||||
# The rejection surfaces at consent time (the earliest resolve path).
|
||||
with pytest.raises(ExtensionSidecarProxyError):
|
||||
set_extension_sidecar_proxy_consent("templates", True)
|
||||
|
||||
|
||||
def test_status_payload_flags_local_unprotected_when_auth_off(tmp_path, monkeypatch):
|
||||
_token_v1_manifest(monkeypatch, tmp_path)
|
||||
from api.extensions import get_extension_status
|
||||
|
||||
status = get_extension_status()
|
||||
sidecars = status.get("sidecars") or []
|
||||
tmpl = next((s for s in sidecars if s.get("id") == "templates"), None)
|
||||
assert tmpl is not None
|
||||
proxy = tmpl["proxy"]
|
||||
assert proxy["proxy_auth"] == "token-v1"
|
||||
assert proxy["posture"] == "local_unprotected" # auth off -> panel warns pre-consent
|
||||
|
||||
|
||||
def test_inbound_x_hermes_header_is_stripped(monkeypatch):
|
||||
from api.routes import _extension_sidecar_proxy_request_headers
|
||||
|
||||
class H:
|
||||
headers = {
|
||||
"X-Hermes-Sidecar-Token": "forged",
|
||||
"X-Custom": "ok",
|
||||
"Cookie": "secret",
|
||||
}
|
||||
|
||||
out = _extension_sidecar_proxy_request_headers(H())
|
||||
assert "X-Custom" in out
|
||||
assert not any(k.lower().startswith("x-hermes-") for k in out)
|
||||
assert not any(k.lower() == "cookie" for k in out)
|
||||
|
||||
|
||||
def test_token_module_persists_and_rotates(tmp_path, monkeypatch):
|
||||
import api.extension_sidecar_auth as sc
|
||||
|
||||
# Patch the dynamic dir resolver (mirrors how _extension_state_dir is patched
|
||||
# elsewhere) so the token lands in an isolated tmp dir, not the shared session
|
||||
# state dir.
|
||||
token_dir = tmp_path / "sidecar-auth"
|
||||
monkeypatch.setattr(sc, "_token_dir", lambda: token_dir)
|
||||
sc._CACHE.clear()
|
||||
tok = sc.ensure_token("templates")
|
||||
assert tok and sc.current_token("templates") == tok
|
||||
assert (token_dir / "templates.token").exists()
|
||||
assert sc.current_token("never") is None # fail closed
|
||||
assert sc.ensure_token("../evil") is None # path-escape rejected
|
||||
# IDs are filename components. Reject rather than silently trim whitespace so
|
||||
# validation and _token_path() always operate on the exact same string.
|
||||
for invalid_id in ("templates\n", " templates", "templates "):
|
||||
assert sc.ensure_token(invalid_id) is None
|
||||
assert not (token_dir / f"{invalid_id}.token").exists()
|
||||
# canonical (wider) id grammar is honored: uppercase/dot ids work
|
||||
assert sc.ensure_token("RSS.Feeds") is not None
|
||||
new = sc.reset_token("templates")
|
||||
assert new and new != tok and sc.current_token("templates") == new
|
||||
sc._CACHE.clear()
|
||||
|
||||
|
||||
def test_token_v1_route_injects_token_and_strips_response(monkeypatch):
|
||||
# The 5 most load-bearing lines: the injected token must reach the upstream
|
||||
# Request, and any x-hermes-* on the response must be stripped before it
|
||||
# reaches the browser. (Fable coreA item 3.)
|
||||
from api import routes
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.status = 200
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hermes-Echo": "leak-me", # must be stripped from client response
|
||||
}
|
||||
|
||||
def read(self, *_a):
|
||||
return b"{}"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
class FakeOpener:
|
||||
def open(self, request, timeout=10):
|
||||
captured["headers"] = {k.lower(): v for k, v in request.header_items()}
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
"api.extensions.resolve_extension_sidecar_proxy_target",
|
||||
lambda extension_id, proxy_path, query="": {
|
||||
"extension_id": extension_id,
|
||||
"origin": "http://127.0.0.1:17787",
|
||||
"proxy_path": "/api/extensions/templates/sidecar/",
|
||||
"upstream_url": "http://127.0.0.1:17787/v1/ping",
|
||||
"proxy_auth": "token-v1",
|
||||
"auth_token": "injected-token-abc",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes, "_extension_sidecar_proxy_same_origin_opener", lambda o: FakeOpener()
|
||||
)
|
||||
|
||||
handler = FakeHandler()
|
||||
handler.headers = {
|
||||
"Accept": "application/json",
|
||||
"Host": "webui.local",
|
||||
"Origin": "http://webui.local",
|
||||
"Referer": "http://webui.local/",
|
||||
}
|
||||
result = routes.handle_get(
|
||||
handler,
|
||||
SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""),
|
||||
)
|
||||
assert result is True
|
||||
# token injected on the way to the sidecar
|
||||
assert captured["headers"].get("x-hermes-sidecar-token") == "injected-token-abc"
|
||||
# token/x-hermes header stripped on the way back to the browser
|
||||
assert handler.header("X-Hermes-Echo") is None
|
||||
@@ -6,6 +6,7 @@ PR #4495 fixed the runs API path but left the legacy path without approval handl
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -336,6 +337,19 @@ def test_legacy_approval_records_run_id_for_response_relay():
|
||||
# (connection still open, run parked) — this test's stream already ran to
|
||||
# completion, which cleared active_stream_id and popped the mapping.
|
||||
_STREAM_RUN_IDS[stream_id] = "run-legacy-1"
|
||||
from types import SimpleNamespace
|
||||
from api import route_approvals as ra
|
||||
with ra._lock:
|
||||
ra._gateway_queues["sess-legacy-runid"] = [SimpleNamespace(data={
|
||||
"command": "rm -rf /tmp/test",
|
||||
"description": "Delete temporary files",
|
||||
"pattern_key": "dangerous_command",
|
||||
"pattern_keys": ["dangerous_command"],
|
||||
"approval_id": "appr-legacy-runid",
|
||||
"run_id": "run-legacy-1",
|
||||
})]
|
||||
live_entry = ra._gateway_queues["sess-legacy-runid"][0]
|
||||
ra.submit_gateway_pending_mirror("sess-legacy-runid", live_entry.data)
|
||||
relay_session = MagicMock()
|
||||
relay_session.active_stream_id = stream_id
|
||||
captured = {}
|
||||
@@ -360,7 +374,7 @@ def test_legacy_approval_records_run_id_for_response_relay():
|
||||
assert captured.get("url", "") == "http://gw:8642/v1/runs/run-legacy-1/approval", (
|
||||
f"approval respond must relay to the gateway run; got {captured.get('url')!r}"
|
||||
)
|
||||
assert captured["body"] == {"choice": "once", "approval_id": "appr-legacy-runid"}
|
||||
assert captured["body"] == {"choice": "once", "approval_id": ""}
|
||||
handler.send_response.assert_called_with(200)
|
||||
finally:
|
||||
with STREAMS_LOCK:
|
||||
@@ -447,8 +461,8 @@ def test_legacy_teardown_clears_stale_gateway_mirror_and_notifies_empty_state():
|
||||
ra._gateway_queues.pop(session_id, None)
|
||||
|
||||
|
||||
def test_legacy_teardown_preserves_live_gateway_head_mirror():
|
||||
"""A live gateway head must still be mirrored after legacy teardown runs."""
|
||||
def test_legacy_teardown_retires_live_gateway_head_mirror():
|
||||
"""A mapped legacy run retires its live gateway mirror during outer teardown."""
|
||||
from types import SimpleNamespace
|
||||
from api import route_approvals as ra
|
||||
from api.config import STREAMS, STREAMS_LOCK
|
||||
@@ -507,14 +521,11 @@ def test_legacy_teardown_preserves_live_gateway_head_mirror():
|
||||
|
||||
payloads = _drain_queue(subscriber)
|
||||
assert payloads, "Expected mirrored approval notifications"
|
||||
assert payloads[-1]["pending"]["_gateway_mirror"] is True
|
||||
assert payloads[-1]["pending_count"] == 1
|
||||
assert payloads[-1]["pending"] is None
|
||||
assert payloads[-1]["pending_count"] == 0
|
||||
with ra._lock:
|
||||
pending = ra._pending.get(session_id)
|
||||
assert isinstance(pending, list)
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["approval_id"] == approval_data["approval_id"]
|
||||
assert pending[0]["_gateway_mirror"] is True
|
||||
assert pending is None
|
||||
finally:
|
||||
ra._approval_sse_unsubscribe(session_id, subscriber)
|
||||
with STREAMS_LOCK:
|
||||
@@ -686,7 +697,7 @@ def test_mirrored_run_id_survives_active_stream_loss():
|
||||
assert captured.get("url", "") == f"http://gw:8642/v1/runs/{run_id}/approval", (
|
||||
f"approval respond must relay to the mirrored gateway run; got {captured.get('url')!r}"
|
||||
)
|
||||
assert captured["body"] == {"choice": "once", "approval_id": approval_id}
|
||||
assert captured["body"] == {"choice": "once", "approval_id": ""}
|
||||
handler.send_response.assert_called_with(200)
|
||||
assert entry.event.is_set(), "mirrored gateway approval was not resolved"
|
||||
assert entry.result == "once"
|
||||
@@ -752,8 +763,8 @@ def test_gateway_mode_no_pending_click_stays_non_409():
|
||||
assert captured["payload"].get("code") != "gateway_run_unavailable"
|
||||
|
||||
|
||||
def test_legacy_approval_without_run_id_stays_actionable():
|
||||
"""Legacy approvals without a run_id must fail explicitly and keep the mirror live."""
|
||||
def test_legacy_approval_without_run_id_retires_locally():
|
||||
"""A no-run legacy mirror retires locally instead of becoming a ghost card."""
|
||||
from types import SimpleNamespace
|
||||
from api import routes as r
|
||||
from api import route_approvals as ra
|
||||
@@ -832,22 +843,15 @@ def test_legacy_approval_without_run_id_stays_actionable():
|
||||
]
|
||||
assert approval_events
|
||||
approval_data = approval_events[0][1]
|
||||
local_entry = SimpleNamespace(data=dict(approval_data), event=threading.Event(), result=None)
|
||||
with ra._lock:
|
||||
r._gateway_queues[session_id] = [SimpleNamespace(data=dict(approval_data))]
|
||||
r._gateway_queues[session_id] = [local_entry]
|
||||
ra.submit_gateway_pending_mirror(session_id, approval_data)
|
||||
with ra._lock:
|
||||
pending_queue = r._pending.get(session_id)
|
||||
assert isinstance(pending_queue, list)
|
||||
approval_id = pending_queue[0]["approval_id"]
|
||||
|
||||
# The relay-unavailable 409 is only meaningful when the WebUI is
|
||||
# actually running the gateway chat backend. On a gateway deployment
|
||||
# the backend env is process-wide (not just during the stream), so
|
||||
# assert the 409 with HERMES_WEBUI_CHAT_BACKEND=gateway active at
|
||||
# respond time. Without this scope the handler now (correctly) treats
|
||||
# a mirrored approval on the default LOCAL backend as locally
|
||||
# resolvable and falls through instead of 409ing — see
|
||||
# test_issue4771_local_approval_regression.py (#4771 follow-up).
|
||||
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}), \
|
||||
patch("api.routes.get_session", return_value=mock_session), \
|
||||
patch("api.routes.j", new=fake_j):
|
||||
@@ -856,13 +860,12 @@ def test_legacy_approval_without_run_id_stays_actionable():
|
||||
{"session_id": session_id, "choice": "once", "approval_id": approval_id},
|
||||
)
|
||||
|
||||
assert captured["status"] == 409
|
||||
assert captured["payload"]["code"] == "gateway_run_unavailable"
|
||||
assert captured["payload"]["error"] == r._GATEWAY_APPROVAL_RELAY_UNAVAILABLE
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"] == {"ok": True, "choice": "once", "local_retired": True}
|
||||
assert local_entry.event.is_set()
|
||||
assert local_entry.result == "once"
|
||||
with ra._lock:
|
||||
pending_queue = r._pending.get(session_id)
|
||||
assert isinstance(pending_queue, list)
|
||||
assert pending_queue[0]["approval_id"] == approval_id
|
||||
assert session_id not in r._pending
|
||||
finally:
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop(stream_id, None)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -767,8 +767,21 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
|
||||
native_result = [
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"id": "vision-1"}]},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "vision-1",
|
||||
"content": [
|
||||
{"type": "text", "text": "Vision result"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
{"type": "document", "document": {"id": "doc-1"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
replayed_result = previous_context + previous_context + [
|
||||
{"role": "user", "content": "simple follow-up"},
|
||||
*native_result,
|
||||
{"role": "assistant", "content": "short answer"},
|
||||
]
|
||||
|
||||
@@ -793,6 +806,7 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey
|
||||
|
||||
assert handler.status == 200
|
||||
reloaded = Session.load(session.session_id)
|
||||
assert reloaded is not None
|
||||
|
||||
# Stable per-message ids (#context-message-stable-id) are now stamped on
|
||||
# context rows; compare on the semantic fields so the dedup invariant is
|
||||
@@ -807,9 +821,23 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey
|
||||
{"role": "user", "content": "[Session Arc Summary (d1, node 39)]\n" + "old context\n" * 400},
|
||||
{"role": "assistant", "content": "previous answer"},
|
||||
{"role": "user", "content": "simple follow-up"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"id": "vision-1"}]},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "vision-1",
|
||||
"content": [
|
||||
{"type": "text", "text": "Vision result"},
|
||||
{"type": "text", "text": "[screenshot]"},
|
||||
{"type": "document", "document": {"id": "doc-1"}},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "short answer"},
|
||||
]
|
||||
assert _no_id(reloaded.context_messages) == expected
|
||||
assert "data:image" not in json.dumps(reloaded.context_messages)
|
||||
assert "data:image" not in json.dumps(reloaded.messages)
|
||||
assert any(row.get("tool_call_id") == "vision-1" for row in reloaded.context_messages)
|
||||
assert any(row.get("tool_call_id") == "vision-1" for row in reloaded.messages)
|
||||
assert _no_id(reloaded.context_messages).count(expected[0]) == 1
|
||||
# The new turn's rows carry unique stable ids. (This session preloads
|
||||
# id-less legacy rows, which stay id-less until they age out of context;
|
||||
|
||||
@@ -129,6 +129,12 @@ def _scrub_provider_env(monkeypatch):
|
||||
"XIAOMI_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
"NOUS_API_KEY", "NVIDIA_API_KEY", "LM_API_KEY", "LMSTUDIO_API_KEY",
|
||||
# Model-selection overrides: get_effective_default_model() treats these
|
||||
# as the highest-priority default-model source, above config. A runner
|
||||
# that exports HERMES_MODEL (e.g. a live agent session set to "opus-4.8")
|
||||
# injects that id into the picker/catalog and breaks the @nous: prefix
|
||||
# invariant below. Scrub so detection sees only the installed stubs.
|
||||
"HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
@@ -130,6 +130,11 @@ def _scrub_provider_env(monkeypatch):
|
||||
"XIAOMI_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
"NOUS_API_KEY", "NVIDIA_API_KEY", "LM_API_KEY", "LMSTUDIO_API_KEY",
|
||||
# Model-selection overrides: get_effective_default_model() treats these
|
||||
# as the highest-priority default-model source, above config. A runner
|
||||
# exporting HERMES_MODEL (e.g. a live agent session on "opus-4.8") injects
|
||||
# that id into the picker/catalog and breaks capacity/symmetry assertions.
|
||||
"HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
@@ -33,3 +33,118 @@ def test_set_hermes_home_patches_both_skill_tool_module_caches(monkeypatch, tmp_
|
||||
assert skills_tool.SKILLS_DIR == new_home / "skills"
|
||||
assert skill_manager_tool.HERMES_HOME == new_home
|
||||
assert skill_manager_tool.SKILLS_DIR == new_home / "skills"
|
||||
|
||||
|
||||
def test_skill_modules_support_profile_home_returns_true_for_dynamic_modules(monkeypatch, tmp_path):
|
||||
from api.profiles import _skill_modules_support_profile_home
|
||||
|
||||
profile_home = tmp_path / "profile"
|
||||
expected = profile_home / "skills"
|
||||
baseline = tmp_path / "base" / "skills"
|
||||
|
||||
skills_tool = types.ModuleType("tools.skills_tool")
|
||||
skills_tool.HERMES_HOME = profile_home.parent
|
||||
skills_tool.SKILLS_DIR = baseline
|
||||
skills_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skills_tool._skills_dir = lambda: expected
|
||||
skill_manager_tool = types.ModuleType("tools.skill_manager_tool")
|
||||
skill_manager_tool.HERMES_HOME = profile_home.parent
|
||||
skill_manager_tool.SKILLS_DIR = baseline
|
||||
skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skill_manager_tool._skills_dir = lambda: expected
|
||||
|
||||
monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool)
|
||||
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool)
|
||||
|
||||
assert _skill_modules_support_profile_home(profile_home) is True
|
||||
|
||||
|
||||
def test_skill_modules_support_profile_home_returns_false_when_already_globally_patched(monkeypatch, tmp_path):
|
||||
from api.profiles import _skill_modules_support_profile_home
|
||||
|
||||
profile_home = tmp_path / "profile"
|
||||
expected = profile_home / "skills"
|
||||
baseline = tmp_path / "base" / "skills"
|
||||
patched = tmp_path / "alpha" / "skills"
|
||||
|
||||
skills_tool = types.ModuleType("tools.skills_tool")
|
||||
skills_tool.HERMES_HOME = profile_home.parent
|
||||
skills_tool.SKILLS_DIR = patched
|
||||
skills_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skills_tool._skills_dir = lambda: expected
|
||||
|
||||
skill_manager_tool = types.ModuleType("tools.skill_manager_tool")
|
||||
skill_manager_tool.HERMES_HOME = profile_home.parent
|
||||
skill_manager_tool.SKILLS_DIR = baseline
|
||||
skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skill_manager_tool._skills_dir = lambda: expected
|
||||
|
||||
monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool)
|
||||
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool)
|
||||
|
||||
assert _skill_modules_support_profile_home(profile_home) is False
|
||||
|
||||
|
||||
def test_skill_modules_support_profile_home_returns_false_when_module_is_static(monkeypatch, tmp_path):
|
||||
from api.profiles import _skill_modules_support_profile_home
|
||||
|
||||
profile_home = tmp_path / "profile"
|
||||
expected = profile_home / "skills"
|
||||
baseline = tmp_path / "base" / "skills"
|
||||
|
||||
skills_tool = types.ModuleType("tools.skills_tool")
|
||||
skills_tool.SKILLS_DIR = baseline
|
||||
skills_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skills_tool._skills_dir = expected
|
||||
skill_manager_tool = types.ModuleType("tools.skill_manager_tool")
|
||||
skill_manager_tool.SKILLS_DIR = baseline
|
||||
skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skill_manager_tool._skills_dir = lambda: expected
|
||||
|
||||
monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool)
|
||||
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool)
|
||||
|
||||
assert _skill_modules_support_profile_home(profile_home) is False
|
||||
|
||||
|
||||
def test_skill_modules_support_profile_home_returns_false_when_resolver_raises(monkeypatch, tmp_path):
|
||||
from api.profiles import _skill_modules_support_profile_home
|
||||
|
||||
profile_home = tmp_path / "profile"
|
||||
expected = profile_home / "skills"
|
||||
baseline = tmp_path / "base" / "skills"
|
||||
|
||||
def _raise():
|
||||
raise RuntimeError("not callable")
|
||||
|
||||
skills_tool = types.ModuleType("tools.skills_tool")
|
||||
skills_tool.SKILLS_DIR = baseline
|
||||
skills_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skills_tool._skills_dir = _raise
|
||||
skill_manager_tool = types.ModuleType("tools.skill_manager_tool")
|
||||
skill_manager_tool.SKILLS_DIR = baseline
|
||||
skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skill_manager_tool._skills_dir = lambda: expected
|
||||
|
||||
monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool)
|
||||
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool)
|
||||
|
||||
assert _skill_modules_support_profile_home(profile_home) is False
|
||||
|
||||
|
||||
def test_skill_modules_support_profile_home_returns_false_when_module_missing(monkeypatch, tmp_path):
|
||||
from api.profiles import _skill_modules_support_profile_home
|
||||
|
||||
profile_home = tmp_path / "profile"
|
||||
expected = profile_home / "skills"
|
||||
baseline = tmp_path / "base" / "skills"
|
||||
|
||||
skill_manager_tool = types.ModuleType("tools.skill_manager_tool")
|
||||
skill_manager_tool.SKILLS_DIR = baseline
|
||||
skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline
|
||||
skill_manager_tool._skills_dir = lambda: expected
|
||||
|
||||
monkeypatch.delitem(sys.modules, "tools.skills_tool", raising=False)
|
||||
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool)
|
||||
|
||||
assert _skill_modules_support_profile_home(profile_home) is False
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Regression coverage for #2848 checkpoint saves in background threads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
|
||||
def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path):
|
||||
@@ -20,7 +22,11 @@ def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home)
|
||||
monkeypatch.setattr(profiles, "get_profile_runtime_env", lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")})
|
||||
monkeypatch.setattr(
|
||||
profiles,
|
||||
"get_profile_runtime_env",
|
||||
lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")},
|
||||
)
|
||||
|
||||
def fake_save(self, *args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
@@ -35,3 +41,73 @@ def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path):
|
||||
assert captured["kwargs"] == {"skip_index": True}
|
||||
assert captured["thread_env"]["HERMES_HOME"] == str(profile_home)
|
||||
assert captured["thread_env"]["HERMES_CONFIG_PATH"] == str(profile_home / "config.yaml")
|
||||
|
||||
|
||||
def test_checkpoint_save_completes_without_skill_lock(monkeypatch, tmp_path):
|
||||
"""Checkpoint saves must not block on the legacy skill module lock."""
|
||||
|
||||
from api.models import Session
|
||||
from api.streaming import _save_streaming_checkpoint
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
profile_home = tmp_path / "profiles" / "maiko"
|
||||
profile_home.mkdir(parents=True)
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home)
|
||||
monkeypatch.setattr(
|
||||
profiles,
|
||||
"get_profile_runtime_env",
|
||||
lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")},
|
||||
)
|
||||
monkeypatch.setattr(profiles, "_resolve_hermes_home_override", lambda: None)
|
||||
|
||||
patch_calls: list[dict] = []
|
||||
|
||||
def fake_save(self, *args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
captured["thread_env"] = dict(getattr(config._thread_ctx, "env", {}) or {})
|
||||
captured["env_hermes_home"] = os.environ.get("HERMES_HOME")
|
||||
|
||||
def patch_skill_home_modules(*_):
|
||||
patch_calls.append({"patched": True})
|
||||
|
||||
monkeypatch.setattr(Session, "save", fake_save)
|
||||
monkeypatch.setattr(profiles, "patch_skill_home_modules", patch_skill_home_modules)
|
||||
|
||||
completion = threading.Event()
|
||||
|
||||
session = Session(session_id="issue2848-lock", profile="maiko")
|
||||
|
||||
acquired_lock = profiles._SKILL_HOME_MODULE_PATCH_LOCK.acquire(timeout=1)
|
||||
assert acquired_lock, "lock was unexpectedly unavailable before checkpoint test"
|
||||
|
||||
try:
|
||||
def _worker() -> None:
|
||||
try:
|
||||
_save_streaming_checkpoint(session)
|
||||
completion.set()
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
captured["error"] = exc
|
||||
completion.set()
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
worker.start()
|
||||
|
||||
assert completion.wait(0.5), (
|
||||
"checkpoint worker should not block on skill module lock"
|
||||
)
|
||||
finally:
|
||||
profiles._SKILL_HOME_MODULE_PATCH_LOCK.release()
|
||||
worker.join(timeout=1)
|
||||
|
||||
assert captured.get("kwargs") == {"skip_index": True}
|
||||
assert captured.get("thread_env", {}).get("HERMES_HOME") == str(profile_home)
|
||||
assert (
|
||||
captured.get("thread_env", {}).get("HERMES_CONFIG_PATH")
|
||||
== str(profile_home / "config.yaml")
|
||||
)
|
||||
assert captured.get("env_hermes_home") == str(profile_home)
|
||||
assert not patch_calls
|
||||
assert "error" not in captured
|
||||
@@ -322,7 +322,11 @@ def test_chat_activity_display_mode_settled_hide_all_scene_persists_without_work
|
||||
return_index = block.index("return hasWorklogRows;")
|
||||
|
||||
assert "const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);" in block
|
||||
assert "const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity';" in block
|
||||
assert "const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);" in block
|
||||
assert (
|
||||
"const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes;"
|
||||
in block
|
||||
)
|
||||
assert persist_index < return_index
|
||||
assert "return true;" not in block
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
import pytest
|
||||
from unittest import mock
|
||||
@@ -14,6 +15,19 @@ import api.streaming as streaming
|
||||
from api.models import Session
|
||||
|
||||
|
||||
@pytest.mark.parametrize("started_at", [None, 0, -1, "invalid", float("nan"), float("inf"), 101.0, 10**400])
|
||||
def test_terminal_turn_duration_omits_invalid_origin(started_at):
|
||||
session = types.SimpleNamespace(pending_started_at=started_at)
|
||||
|
||||
assert streaming._terminal_turn_duration(session, now=100.0) is None
|
||||
|
||||
|
||||
def test_terminal_turn_duration_freezes_valid_origin():
|
||||
session = types.SimpleNamespace(pending_started_at=88.7654)
|
||||
|
||||
assert streaming._terminal_turn_duration(session, now=100.0) == 11.235
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_session_dir(tmp_path, monkeypatch):
|
||||
"""Redirect SESSION_DIR / SESSION_INDEX_FILE to an isolated temp dir."""
|
||||
@@ -111,6 +125,7 @@ def test_silent_failure_preserves_partials(tmp_path):
|
||||
"""Test that a silent failure (agent returns no assistant reply) preserves partial streamed text."""
|
||||
fake_session = Session(session_id="test_sess_silent", title="Test Session")
|
||||
fake_session.pending_user_message = "What is python?"
|
||||
fake_session.pending_started_at = time.time() - 12
|
||||
fake_session.active_stream_id = "test_stream_silent"
|
||||
fake_session.save()
|
||||
models.SESSIONS["test_sess_silent"] = fake_session
|
||||
@@ -161,12 +176,65 @@ def test_silent_failure_preserves_partials(tmp_path):
|
||||
|
||||
err_msg = saved.messages[-1]
|
||||
assert err_msg.get("_error") is True
|
||||
assert err_msg.get("_turnDuration", 0) >= 12
|
||||
|
||||
|
||||
@pytest.mark.parametrize("started_at", [None, "future"])
|
||||
def test_result_error_omits_turn_duration_for_missing_or_future_origin(tmp_path, started_at):
|
||||
"""Result-error writeback must not manufacture durations from invalid timer origins."""
|
||||
label = "future" if started_at == "future" else "missing"
|
||||
fake_session = Session(session_id=f"test_sess_silent_{label}", title="Test Session")
|
||||
fake_session.pending_user_message = "What is python?"
|
||||
fake_session.pending_started_at = time.time() + 30 if started_at == "future" else started_at
|
||||
fake_session.active_stream_id = f"test_stream_silent_{label}"
|
||||
fake_session.save()
|
||||
models.SESSIONS[fake_session.session_id] = fake_session
|
||||
|
||||
class SilentFailureAgent(MockAgent):
|
||||
def run_conversation(self, **kwargs):
|
||||
if self.stream_delta_callback:
|
||||
self.stream_delta_callback("Python is a programming language.")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "Silent failure details",
|
||||
"messages": kwargs.get("conversation_history") or [],
|
||||
}
|
||||
|
||||
fake_queue = queue.Queue()
|
||||
streaming.STREAMS[fake_session.active_stream_id] = fake_queue
|
||||
config.STREAM_PARTIAL_TEXT[fake_session.active_stream_id] = ""
|
||||
|
||||
with mock.patch.object(streaming, "get_session", return_value=fake_session), \
|
||||
mock.patch.object(streaming, "_get_ai_agent", return_value=SilentFailureAgent), \
|
||||
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
|
||||
mock.patch("api.config.get_config", return_value={}), \
|
||||
mock.patch("api.config._resolve_cli_toolsets", return_value=[]):
|
||||
|
||||
streaming._run_agent_streaming(
|
||||
session_id=fake_session.session_id,
|
||||
msg_text="What is python?",
|
||||
model="test-model",
|
||||
workspace=str(tmp_path),
|
||||
stream_id=fake_session.active_stream_id,
|
||||
)
|
||||
|
||||
saved = Session.load(fake_session.session_id)
|
||||
assert saved is not None
|
||||
assert saved.messages[-1].get("_error") is True
|
||||
assert "_turnDuration" not in saved.messages[-1]
|
||||
|
||||
apperrors = [item[1] for item in list(fake_queue.queue) if item[0] == "apperror"]
|
||||
assert apperrors
|
||||
payload_error = apperrors[-1]["session"]["messages"][-1]
|
||||
assert payload_error.get("_error") is True
|
||||
assert "_turnDuration" not in payload_error
|
||||
|
||||
|
||||
def test_exception_preserves_partials(tmp_path):
|
||||
"""Test that an unhandled exception preserves partial streamed text."""
|
||||
fake_session = Session(session_id="test_sess_exc", title="Test Session")
|
||||
fake_session.pending_user_message = "Exception test"
|
||||
fake_session.pending_started_at = time.time() - 9
|
||||
fake_session.active_stream_id = "test_stream_exc"
|
||||
fake_session.save()
|
||||
models.SESSIONS["test_sess_exc"] = fake_session
|
||||
@@ -205,6 +273,48 @@ def test_exception_preserves_partials(tmp_path):
|
||||
err_msg = saved.messages[-1]
|
||||
assert err_msg.get("_error") is True
|
||||
assert "Fake provider crash!" in err_msg.get("content", "")
|
||||
assert err_msg.get("_turnDuration", 0) >= 9
|
||||
|
||||
|
||||
@pytest.mark.parametrize("started_at", [None, "future"])
|
||||
def test_exception_error_omits_turn_duration_for_missing_or_future_origin(tmp_path, started_at):
|
||||
"""Exception writeback must not persist durations from invalid timer origins."""
|
||||
label = "future" if started_at == "future" else "missing"
|
||||
fake_session = Session(session_id=f"test_sess_exc_{label}", title="Test Session")
|
||||
fake_session.pending_user_message = "Exception test"
|
||||
fake_session.pending_started_at = time.time() + 30 if started_at == "future" else started_at
|
||||
fake_session.active_stream_id = f"test_stream_exc_{label}"
|
||||
fake_session.save()
|
||||
models.SESSIONS[fake_session.session_id] = fake_session
|
||||
|
||||
class ExceptionAgent(MockAgent):
|
||||
def run_conversation(self, **kwargs):
|
||||
if self.stream_delta_callback:
|
||||
self.stream_delta_callback("Stream before crash.")
|
||||
raise RuntimeError("Fake provider crash!")
|
||||
|
||||
fake_queue = queue.Queue()
|
||||
streaming.STREAMS[fake_session.active_stream_id] = fake_queue
|
||||
config.STREAM_PARTIAL_TEXT[fake_session.active_stream_id] = ""
|
||||
|
||||
with mock.patch.object(streaming, "get_session", return_value=fake_session), \
|
||||
mock.patch.object(streaming, "_get_ai_agent", return_value=ExceptionAgent), \
|
||||
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
|
||||
mock.patch("api.config.get_config", return_value={}), \
|
||||
mock.patch("api.config._resolve_cli_toolsets", return_value=[]):
|
||||
|
||||
streaming._run_agent_streaming(
|
||||
session_id=fake_session.session_id,
|
||||
msg_text="Exception test",
|
||||
model="test-model",
|
||||
workspace=str(tmp_path),
|
||||
stream_id=fake_session.active_stream_id,
|
||||
)
|
||||
|
||||
saved = Session.load(fake_session.session_id)
|
||||
assert saved is not None
|
||||
assert saved.messages[-1].get("_error") is True
|
||||
assert "_turnDuration" not in saved.messages[-1]
|
||||
|
||||
|
||||
def test_empty_partials_do_not_create_spurious_messages(tmp_path):
|
||||
|
||||
@@ -111,19 +111,43 @@ def _insert(sid_session):
|
||||
|
||||
# ─────────────────────────── config knob ────────────────────────────────────
|
||||
|
||||
def test_default_sessions_cache_cap_is_100():
|
||||
"""The shipped no-override session cache default is 100 (#6351)."""
|
||||
from api import config as _cfg
|
||||
|
||||
assert _cfg.DEFAULT_SESSIONS_CACHE_MAX == 100
|
||||
|
||||
|
||||
def test_cache_cap_reads_config_yaml_key():
|
||||
"""The cap is configurable via config.yaml webui.sessions_cache_max (#4765)."""
|
||||
from api import config as _cfg
|
||||
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42
|
||||
# Invalid / missing values must fall back, never disable the bound.
|
||||
fallback = _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": "nope"}})
|
||||
assert isinstance(fallback, int) and fallback >= 1
|
||||
assert _cfg.get_sessions_cache_max({"webui": {}}) >= 1
|
||||
assert _cfg.get_sessions_cache_max({}) >= 1
|
||||
# A zero/negative typo must not disable the cap.
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 0}}) >= 1
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": -5}}) >= 1
|
||||
old_sessions_max = _cfg.SESSIONS_MAX
|
||||
try:
|
||||
_cfg.SESSIONS_MAX = 222
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42
|
||||
# Invalid / missing values must fall back, never disable the bound.
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": "nope"}}) == 222
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 0}}) == 222
|
||||
assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": -5}}) == 222
|
||||
finally:
|
||||
_cfg.SESSIONS_MAX = old_sessions_max
|
||||
|
||||
|
||||
def test_cache_cap_preserves_environment_fallback():
|
||||
"""The parsed env fallback still wins when config is absent or invalid (#6351)."""
|
||||
from api import config as _cfg
|
||||
|
||||
old_sessions_max = _cfg.SESSIONS_MAX
|
||||
try:
|
||||
_cfg.SESSIONS_MAX = 222
|
||||
assert _cfg.get_sessions_cache_max({"webui": {}}) == 222
|
||||
assert _cfg.get_sessions_cache_max({}) == 222
|
||||
_cfg.SESSIONS_MAX = _cfg.DEFAULT_SESSIONS_CACHE_MAX
|
||||
assert _cfg.get_sessions_cache_max({"webui": {}}) == _cfg.DEFAULT_SESSIONS_CACHE_MAX
|
||||
assert _cfg.get_sessions_cache_max({}) == _cfg.DEFAULT_SESSIONS_CACHE_MAX
|
||||
finally:
|
||||
_cfg.SESSIONS_MAX = old_sessions_max
|
||||
|
||||
|
||||
# ─────────────────────────── invariant 1: eviction ──────────────────────────
|
||||
|
||||
@@ -40,6 +40,7 @@ import pytest
|
||||
# Import order matters: tools.* will not resolve until api.config has run.
|
||||
from api import routes
|
||||
from api import models
|
||||
from api.gateway_chat import _STREAM_RUN_IDS
|
||||
|
||||
try:
|
||||
import tools.approval as ta
|
||||
@@ -182,10 +183,206 @@ def test_local_mirrored_approval_deny_resolves():
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_gateway_mirrored_approval_without_run_still_409s():
|
||||
"""#4771 behaviour preserved: on the gateway backend, a mirrored approval
|
||||
whose run is gone must still surface the actionable 409 so the card stays
|
||||
visible instead of silently failing."""
|
||||
def test_local_mirrored_approval_with_gwrun_prefix_still_resolves():
|
||||
"""A local approval id that happens to start with gwrun: stays local."""
|
||||
sid = f"local-approval-prefixed-{uuid.uuid4().hex[:8]}"
|
||||
_register_session(sid)
|
||||
try:
|
||||
entry, _approval_id = _seed_local_pending_approval(sid)
|
||||
with ta._lock:
|
||||
head = ta._pending[sid][0]
|
||||
head["approval_id"] = "gwrun:local-test"
|
||||
handler = _FakeHandler()
|
||||
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False):
|
||||
routes._handle_approval_respond(
|
||||
handler,
|
||||
{"session_id": sid, "choice": "once", "approval_id": "gwrun:local-test"},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is True
|
||||
assert resp.get("code") != "gateway_run_unavailable"
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
finally:
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_local_live_head_does_not_inherit_stale_remote_run():
|
||||
"""A stale remote mirror cannot seize a live local approval with the same id."""
|
||||
sid = f"local-approval-collision-{uuid.uuid4().hex[:8]}"
|
||||
_register_session(sid)
|
||||
try:
|
||||
entry, approval_id = _seed_local_pending_approval(sid)
|
||||
with ta._lock:
|
||||
local_queue = ta._pending.get(sid)
|
||||
local_entries = list(local_queue) if isinstance(local_queue, list) else [local_queue]
|
||||
ta._pending[sid] = [{
|
||||
ra._GATEWAY_MIRROR_FLAG: True,
|
||||
"run_id": "remote-run",
|
||||
"approval_id": approval_id,
|
||||
"command": "stale-remote",
|
||||
}, *local_entries]
|
||||
handler = _FakeHandler()
|
||||
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
|
||||
routes._handle_approval_respond(
|
||||
handler,
|
||||
{"session_id": sid, "choice": "once", "approval_id": approval_id},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is True
|
||||
assert resp.get("relayed") is not True
|
||||
respond_approval.assert_not_called()
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
finally:
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_local_live_head_beats_active_remote_candidate_run():
|
||||
"""A local approval still wins when an active remote run shares its id."""
|
||||
sid = f"local-approval-active-remote-{uuid.uuid4().hex[:8]}"
|
||||
session = _register_session(sid)
|
||||
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
|
||||
session.active_stream_id = stream_id
|
||||
_STREAM_RUN_IDS[stream_id] = "remote-run"
|
||||
try:
|
||||
entry, approval_id = _seed_local_pending_approval(sid)
|
||||
with ta._lock:
|
||||
local_queue = ta._pending.get(sid)
|
||||
local_entries = list(local_queue) if isinstance(local_queue, list) else [local_queue]
|
||||
ta._pending[sid] = [{
|
||||
ra._GATEWAY_MIRROR_FLAG: True,
|
||||
"run_id": "remote-run",
|
||||
"approval_id": approval_id,
|
||||
"command": "remote-collision",
|
||||
}, *local_entries]
|
||||
handler = _FakeHandler()
|
||||
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
|
||||
routes._handle_approval_respond(
|
||||
handler,
|
||||
{"session_id": sid, "choice": "once", "approval_id": approval_id},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is True
|
||||
assert resp.get("relayed") is not True
|
||||
respond_approval.assert_not_called()
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
finally:
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_local_plain_pending_exact_id_beats_remote_mirror():
|
||||
sid = f"local-plain-pending-id-{uuid.uuid4().hex[:8]}"
|
||||
session = _register_session(sid)
|
||||
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
|
||||
session.active_stream_id = stream_id
|
||||
_STREAM_RUN_IDS[stream_id] = "remote-run"
|
||||
try:
|
||||
with ta._lock:
|
||||
ta._pending[sid] = [
|
||||
{"approval_id": "shared-id", "command": "local"},
|
||||
{
|
||||
ra._GATEWAY_MIRROR_FLAG: True,
|
||||
"run_id": "remote-run",
|
||||
"approval_id": "shared-id",
|
||||
"command": "remote",
|
||||
},
|
||||
]
|
||||
handler = _FakeHandler()
|
||||
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval, \
|
||||
patch.object(routes, "_resolve_approval_legacy", return_value=True) as resolve_legacy:
|
||||
routes._handle_approval_respond(
|
||||
handler,
|
||||
{"session_id": sid, "choice": "once", "approval_id": "shared-id"},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is True
|
||||
assert resp.get("relayed") is not True
|
||||
resolve_legacy.assert_called_once_with(sid, "shared-id", "once")
|
||||
respond_approval.assert_not_called()
|
||||
finally:
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_local_plain_pending_exact_id_beats_remote_first_queue_order():
|
||||
sid = f"local-plain-pending-remote-first-{uuid.uuid4().hex[:8]}"
|
||||
session = _register_session(sid)
|
||||
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
|
||||
session.active_stream_id = stream_id
|
||||
_STREAM_RUN_IDS[stream_id] = "remote-run"
|
||||
try:
|
||||
with ta._lock:
|
||||
ta._pending[sid] = [
|
||||
{
|
||||
ra._GATEWAY_MIRROR_FLAG: True,
|
||||
"run_id": "remote-run",
|
||||
"approval_id": "shared-id",
|
||||
"command": "remote",
|
||||
},
|
||||
{"approval_id": "shared-id", "command": "local"},
|
||||
]
|
||||
handler = _FakeHandler()
|
||||
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
|
||||
routes._handle_approval_respond(
|
||||
handler,
|
||||
{"session_id": sid, "choice": "once", "approval_id": "shared-id"},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is True
|
||||
assert resp.get("relayed") is not True
|
||||
respond_approval.assert_not_called()
|
||||
with ta._lock:
|
||||
queue = ta._pending.get(sid) or []
|
||||
assert len(queue) == 1
|
||||
assert queue[0].get("command") == "remote"
|
||||
assert queue[0].get(ra._GATEWAY_MIRROR_FLAG) is True
|
||||
finally:
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_local_plain_pending_without_id_beats_active_remote_candidate_run():
|
||||
sid = f"local-plain-pending-no-id-{uuid.uuid4().hex[:8]}"
|
||||
session = _register_session(sid)
|
||||
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
|
||||
session.active_stream_id = stream_id
|
||||
_STREAM_RUN_IDS[stream_id] = "remote-run"
|
||||
try:
|
||||
with ta._lock:
|
||||
ta._pending[sid] = [{"command": "local"}]
|
||||
handler = _FakeHandler()
|
||||
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval, \
|
||||
patch.object(routes, "_resolve_approval_legacy", return_value=True) as resolve_legacy:
|
||||
routes._handle_approval_respond(
|
||||
handler,
|
||||
{"session_id": sid, "choice": "once"},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is True
|
||||
assert resp.get("relayed") is not True
|
||||
resolve_legacy.assert_called_once_with(sid, "", "once")
|
||||
respond_approval.assert_not_called()
|
||||
finally:
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
def test_gateway_mirrored_approval_without_run_retires_locally():
|
||||
"""A gateway mirror without a run retires instead of leaving a ghost card."""
|
||||
sid = f"gw-approval-{uuid.uuid4().hex[:8]}"
|
||||
_register_session(sid) # no active_stream_id -> no _STREAM_RUN_IDS mapping
|
||||
try:
|
||||
@@ -197,10 +394,13 @@ def test_gateway_mirrored_approval_without_run_still_409s():
|
||||
{"session_id": sid, "choice": "once", "approval_id": approval_id},
|
||||
)
|
||||
resp = handler.json()
|
||||
assert handler.status == 409, f"expected 409, got {handler.status}: {resp}"
|
||||
assert resp.get("ok") is False
|
||||
assert resp.get("relayed") is False
|
||||
assert resp.get("code") == "gateway_run_unavailable"
|
||||
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
|
||||
assert resp == {"ok": True, "choice": "once", "local_retired": True}
|
||||
assert entry.event.is_set()
|
||||
assert entry.result == "once"
|
||||
with ta._lock:
|
||||
assert sid not in ta._pending
|
||||
assert sid not in ta._gateway_queues
|
||||
finally:
|
||||
_cleanup(sid)
|
||||
|
||||
|
||||
@@ -171,18 +171,32 @@ def test_stream_done_runs_scroll_preserving_collapse_pass_after_disarm():
|
||||
# UNCONDITIONALLY right after disarm (covers both pin states), THEN scrollToBottom()
|
||||
# only for followers to re-settle at the tail. This makes keep-open genuinely
|
||||
# one-frame for everyone.
|
||||
#
|
||||
# Round 10 (#6385): capture the scroll snapshot from the LIVE DOM before arming
|
||||
# keep-open, so the collapse render below anchors to the content the reader was
|
||||
# actually viewing — not to a stale intermediate state where the worklog was
|
||||
# temporarily expanded. The pre-capture variable must be defined before arm and
|
||||
# threaded into the collapse pass as `_prescrollSnapshot`.
|
||||
pre_capture_idx = MESSAGES_JS.index("_doneLiveScrollSnapshot")
|
||||
arm_idx = MESSAGES_JS.index("_armKeepSettledWorklogOpen")
|
||||
assert pre_capture_idx < arm_idx, (
|
||||
"_doneLiveScrollSnapshot must be captured before _armKeepSettledWorklogOpen "
|
||||
"to snapshot the live (not expanded-worklog) DOM positions."
|
||||
)
|
||||
disarm_idx = MESSAGES_JS.index("_disarmKeepSettledWorklogOpen()")
|
||||
after = MESSAGES_JS[disarm_idx : disarm_idx + 700]
|
||||
# The collapse pass must run after disarm for BOTH pin states.
|
||||
assert "_renderMessagesWithScrollSnapshot()" in after, (
|
||||
# The collapse pass must run after disarm for BOTH pin states, and it must
|
||||
# carry the pre-captured live snapshot.
|
||||
assert "_renderMessagesWithScrollSnapshot({_prescrollSnapshot:_doneLiveScrollSnapshot})" in after, (
|
||||
"after _disarmKeepSettledWorklogOpen() the STREAM_DONE handler must run a "
|
||||
"scroll-preserving collapse pass (_renderMessagesWithScrollSnapshot) so the "
|
||||
"forced-open worklog collapses back to the user/live state without the jump."
|
||||
"scroll-preserving collapse pass (_renderMessagesWithScrollSnapshot) with the "
|
||||
"pre-captured live snapshot so the forced-open worklog collapses back to "
|
||||
"the user/live state without the jump."
|
||||
)
|
||||
# The follower re-settle (scrollToBottom) must come AFTER the collapse render —
|
||||
# otherwise a pinned follower keeps the forced-open DOM (scrollToBottom does not
|
||||
# re-render). This is the exact pinned-path bug the second RED gate-cert caught.
|
||||
collapse_pos = after.index("_renderMessagesWithScrollSnapshot()")
|
||||
collapse_pos = after.index("_renderMessagesWithScrollSnapshot")
|
||||
follow_pos = after.index("shouldFollowOnDone")
|
||||
assert collapse_pos < follow_pos, (
|
||||
"the collapse render must run BEFORE the shouldFollowOnDone scrollToBottom() "
|
||||
@@ -200,3 +214,86 @@ def test_stream_done_runs_scroll_preserving_collapse_pass_after_disarm():
|
||||
assert "_captureMessageScrollSnapshot()" in wrapper
|
||||
assert "_restoreMessageScrollSnapshotSameFrame" in wrapper
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test")
|
||||
def test_prescroll_snapshot_bypasses_capture_no_option_fallback_still_captures():
|
||||
"""Sentinel _prescrollSnapshot reaches restore without re-capture.
|
||||
|
||||
Behavioral harness for _renderMessagesWithScrollSnapshot:
|
||||
|
||||
1. Supply a sentinel snapshot via _prescrollSnapshot, stub
|
||||
_captureMessageScrollSnapshot with a counter — proves the
|
||||
sentinel reaches _restoreMessageScrollSnapshotSameFrame
|
||||
without a second capture.
|
||||
|
||||
2. Call with no options — proves the no-option fallback still
|
||||
calls _captureMessageScrollSnapshot() normally (the contract
|
||||
for callers at static/ui.js:9565, 14416, 14424).
|
||||
|
||||
3. Call with empty options {} — same fallback proof.
|
||||
"""
|
||||
wrapper = _extract("_renderMessagesWithScrollSnapshot")
|
||||
harness = textwrap.dedent(f"""
|
||||
let captureCount = 0;
|
||||
let restoredSnapshot = null;
|
||||
let renderedOptions = null;
|
||||
globalThis._captureMessageScrollSnapshot = () => {{
|
||||
captureCount++;
|
||||
return {{_sentinel: 'fresh-capture', bottom: 0, pinned: true}};
|
||||
}};
|
||||
globalThis.renderMessages = (opts) => {{ renderedOptions = opts; }};
|
||||
globalThis._restoreMessageScrollSnapshotSameFrame = (snap) => {{ restoredSnapshot = snap; }};
|
||||
{wrapper}
|
||||
// Test 1: supplied _prescrollSnapshot -> use it, do NOT capture
|
||||
const sentinel = {{_sentinel: 'pre-captured', bottom: 42, pinned: true}};
|
||||
_renderMessagesWithScrollSnapshot({{_prescrollSnapshot: sentinel}});
|
||||
const test1_usedPrescroll = restoredSnapshot === sentinel;
|
||||
const test1_noCapture = captureCount === 0;
|
||||
const test1_preservedArgs = renderedOptions && renderedOptions._prescrollSnapshot === sentinel;
|
||||
// Reset
|
||||
captureCount = 0; restoredSnapshot = null; renderedOptions = null;
|
||||
// Test 2: no options -> fall back to _captureMessageScrollSnapshot()
|
||||
_renderMessagesWithScrollSnapshot();
|
||||
const test2_captured = captureCount === 1;
|
||||
const test2_usedCapture = restoredSnapshot && restoredSnapshot._sentinel === 'fresh-capture';
|
||||
// Reset
|
||||
captureCount = 0; restoredSnapshot = null; renderedOptions = null;
|
||||
// Test 3: empty options object -> still fall back to capture
|
||||
_renderMessagesWithScrollSnapshot({{}});
|
||||
const test3_captured = captureCount === 1;
|
||||
const test3_usedCapture = restoredSnapshot && restoredSnapshot._sentinel === 'fresh-capture';
|
||||
console.log(JSON.stringify({{
|
||||
test1_usedPrescroll,
|
||||
test1_noCapture,
|
||||
test1_preservedArgs,
|
||||
test2_captured,
|
||||
test2_usedCapture,
|
||||
test3_captured,
|
||||
test3_usedCapture
|
||||
}}));
|
||||
""")
|
||||
res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30)
|
||||
assert res.returncode == 0, res.stderr
|
||||
out = json.loads(res.stdout.strip())
|
||||
assert out["test1_usedPrescroll"] is True, (
|
||||
"supplied _prescrollSnapshot must reach _restoreMessageScrollSnapshotSameFrame"
|
||||
)
|
||||
assert out["test1_noCapture"] is True, (
|
||||
"supplied _prescrollSnapshot must bypass _captureMessageScrollSnapshot()"
|
||||
)
|
||||
assert out["test1_preservedArgs"] is True, (
|
||||
"options passed to _renderMessagesWithScrollSnapshot must be forwarded to renderMessages"
|
||||
)
|
||||
assert out["test2_captured"] is True, (
|
||||
"no-option call must fall back to _captureMessageScrollSnapshot()"
|
||||
)
|
||||
assert out["test2_usedCapture"] is True, (
|
||||
"no-option call's captured snapshot must reach _restoreMessageScrollSnapshotSameFrame"
|
||||
)
|
||||
assert out["test3_captured"] is True, (
|
||||
"empty-options call must fall back to _captureMessageScrollSnapshot()"
|
||||
)
|
||||
assert out["test3_usedCapture"] is True, (
|
||||
"empty-options call's captured snapshot must reach _restoreMessageScrollSnapshotSameFrame"
|
||||
)
|
||||
|
||||
@@ -12,6 +12,40 @@ import pytest
|
||||
from tests.conftest import TEST_BASE, requires_agent_modules
|
||||
|
||||
|
||||
def test_config_snapshot_waits_for_reload_lock(monkeypatch, tmp_path):
|
||||
"""Snapshot capture cannot run concurrently with cache replacement."""
|
||||
import threading
|
||||
|
||||
from api import config
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("{}", encoding="utf-8")
|
||||
shared_config = {"chat_backend": "gateway"}
|
||||
monkeypatch.setattr(config, "_cfg_cache", shared_config)
|
||||
monkeypatch.setattr(config, "cfg", shared_config)
|
||||
monkeypatch.setattr(config, "_cfg_path", config_path)
|
||||
monkeypatch.setattr(config, "_cfg_mtime", config_path.stat().st_mtime)
|
||||
monkeypatch.setattr(config, "_cfg_fingerprint", config._fingerprint_config(shared_config))
|
||||
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
|
||||
|
||||
complete = threading.Event()
|
||||
result = {}
|
||||
|
||||
def read_snapshot():
|
||||
result["snapshot"] = config.get_config_snapshot()
|
||||
complete.set()
|
||||
|
||||
with config._cfg_lock:
|
||||
reader = threading.Thread(target=read_snapshot)
|
||||
reader.start()
|
||||
assert not complete.wait(0.1)
|
||||
reader.join(timeout=1)
|
||||
|
||||
assert complete.is_set()
|
||||
assert result["snapshot"] == shared_config
|
||||
assert result["snapshot"] is not shared_config
|
||||
|
||||
|
||||
def _install_fake_moa_config(monkeypatch, *, default_preset="moa-default", usage_text="Usage: /moa <prompt>"):
|
||||
hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli")
|
||||
# monkeypatch.setattr restores the REAL hermes_cli.__path__ on teardown;
|
||||
@@ -174,7 +208,7 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
monkeypatch.setattr(routes, "get_config", lambda: {"chat_backend": "gateway"})
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway"})
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
monkeypatch.setattr(commands, "resolve_moa_config", resolve_moa_config)
|
||||
|
||||
@@ -192,3 +226,411 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path):
|
||||
body = json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
assert handler.status == 409
|
||||
assert body["error"] == "MoA override is unavailable on gateway-backed sessions"
|
||||
|
||||
|
||||
def test_moa_gateway_configured_default_reaches_start_run(monkeypatch, tmp_path):
|
||||
"""Gateway sessions may use only the configured MoA default."""
|
||||
import api.routes as routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
import io
|
||||
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
self.response_headers.append(("__end__", ""))
|
||||
|
||||
class _Session:
|
||||
session_id = "sess-moa-gateway-default"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "moa"
|
||||
profile = "default"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
|
||||
captured = {}
|
||||
|
||||
def start_run(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_snapshot = {
|
||||
"chat_backend": "gateway",
|
||||
"model": {"provider": "moa", "default": "@moa:moa-configured"},
|
||||
}
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
|
||||
routes._handle_chat_start(
|
||||
_Handler(),
|
||||
{
|
||||
"session_id": "sess-moa-gateway-default",
|
||||
"message": "diagnose issue",
|
||||
"workspace": str(tmp_path),
|
||||
"model": "moa/moa-configured",
|
||||
"model_provider": "moa",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured["model"] == "moa-configured"
|
||||
assert captured["model_provider"] == "moa"
|
||||
assert "moa_config" not in captured
|
||||
|
||||
|
||||
def test_moa_gateway_string_model_config_reaches_start_run(monkeypatch, tmp_path):
|
||||
"""String-form gateway defaults may also authorize MoA sends."""
|
||||
import api.routes as routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
import io
|
||||
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
self.response_headers.append(("__end__", ""))
|
||||
|
||||
class _Session:
|
||||
session_id = "sess-moa-gateway-string-default"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "moa"
|
||||
profile = "default"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
|
||||
captured = {}
|
||||
|
||||
def start_run(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway", "model": "@moa:moa-configured"})
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
|
||||
routes._handle_chat_start(
|
||||
_Handler(),
|
||||
{
|
||||
"session_id": "sess-moa-gateway-string-default",
|
||||
"message": "diagnose issue",
|
||||
"workspace": str(tmp_path),
|
||||
"model": "moa/moa-configured",
|
||||
"model_provider": "moa",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured["model"] == "moa-configured"
|
||||
assert captured["model_provider"] == "moa"
|
||||
assert captured["gateway_chat_enabled"] is True
|
||||
assert "moa_config" not in captured
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_config", "mutated_config", "expected_status"),
|
||||
[
|
||||
(
|
||||
{"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}},
|
||||
{"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}},
|
||||
409,
|
||||
),
|
||||
(
|
||||
{"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}},
|
||||
{"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}},
|
||||
200,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_moa_gateway_authorization_uses_request_owned_config(
|
||||
monkeypatch, tmp_path, initial_config, mutated_config, expected_status
|
||||
):
|
||||
"""Gateway MoA authorization stays on the request's config snapshot."""
|
||||
import io
|
||||
|
||||
import api.routes as routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
self.response_headers.append(("__end__", ""))
|
||||
|
||||
class _Session:
|
||||
session_id = "sess-moa-gateway-config-race"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "moa"
|
||||
profile = "default"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
|
||||
captured = {}
|
||||
config_snapshot = initial_config
|
||||
original_resolve = routes._resolve_compatible_session_model_state
|
||||
|
||||
def resolve_and_mutate(*args, **kwargs):
|
||||
result = original_resolve(*args, **kwargs)
|
||||
config_snapshot.clear()
|
||||
config_snapshot.update(mutated_config)
|
||||
return result
|
||||
|
||||
def start_run(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", resolve_and_mutate)
|
||||
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: routes.copy.deepcopy(config_snapshot))
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
|
||||
handler = _Handler()
|
||||
routes._handle_chat_start(
|
||||
handler,
|
||||
{
|
||||
"session_id": "sess-moa-gateway-config-race",
|
||||
"message": "diagnose issue",
|
||||
"workspace": str(tmp_path),
|
||||
"model": "moa/moa-configured",
|
||||
"model_provider": "moa",
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == expected_status
|
||||
if expected_status == 200:
|
||||
assert captured["gateway_chat_enabled"] is True
|
||||
else:
|
||||
assert not captured
|
||||
|
||||
|
||||
def test_moa_gateway_explicit_configured_default_fails_closed(monkeypatch, tmp_path):
|
||||
"""A browser picker cannot select the gateway's MoA default."""
|
||||
import api.routes as routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
import io
|
||||
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
self.response_headers.append(("__end__", ""))
|
||||
|
||||
class _Session:
|
||||
session_id = "sess-moa-gateway-picker"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "moa"
|
||||
profile = "default"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
|
||||
def start_run(*_args, **_kwargs):
|
||||
raise AssertionError("explicit MoA picker selection must fail before starting a run")
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_snapshot = {
|
||||
"chat_backend": "gateway",
|
||||
"model": {"provider": "moa", "default": "@moa:moa-configured"},
|
||||
}
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
|
||||
handler = _Handler()
|
||||
routes._handle_chat_start(
|
||||
handler,
|
||||
{
|
||||
"session_id": "sess-moa-gateway-picker",
|
||||
"message": "diagnose issue",
|
||||
"workspace": str(tmp_path),
|
||||
"model": "moa/moa-configured",
|
||||
"model_provider": "moa",
|
||||
"explicit_model_pick": True,
|
||||
},
|
||||
)
|
||||
|
||||
body = json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
assert handler.status == 409
|
||||
assert body["error"] == "MoA override is unavailable on gateway-backed sessions"
|
||||
|
||||
|
||||
def test_moa_gateway_spoofed_provider_fails_closed(monkeypatch, tmp_path):
|
||||
"""A client cannot claim MoA for a model outside the configured default."""
|
||||
import api.routes as routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
import io
|
||||
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
self.response_headers.append(("__end__", ""))
|
||||
|
||||
class _Session:
|
||||
session_id = "sess-moa-gateway-spoofed"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "openai-codex"
|
||||
profile = "default"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
|
||||
def start_run(*_args, **_kwargs):
|
||||
raise AssertionError("spoofed MoA provider must fail before starting a run")
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_snapshot = {
|
||||
"chat_backend": "gateway",
|
||||
"model": {"provider": "moa", "default": "@moa:moa-configured"},
|
||||
}
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
|
||||
handler = _Handler()
|
||||
routes._handle_chat_start(
|
||||
handler,
|
||||
{
|
||||
"session_id": "sess-moa-gateway-spoofed",
|
||||
"message": "diagnose issue",
|
||||
"workspace": str(tmp_path),
|
||||
"model": "moa/moa-spoofed",
|
||||
"model_provider": "moa",
|
||||
},
|
||||
)
|
||||
|
||||
body = json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
assert handler.status == 409
|
||||
assert body["error"] == "MoA override is unavailable on gateway-backed sessions"
|
||||
|
||||
|
||||
def test_gateway_non_moa_model_reaches_start_run(monkeypatch, tmp_path):
|
||||
"""Gateway sends keep their non-MoA provider when the default uses MoA."""
|
||||
import api.routes as routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
import io
|
||||
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
self.response_headers.append(("__end__", ""))
|
||||
|
||||
class _Session:
|
||||
session_id = "sess-gateway-non-moa"
|
||||
workspace = str(tmp_path)
|
||||
model = "gpt-5.5"
|
||||
model_provider = "openai-codex"
|
||||
profile = "default"
|
||||
messages = []
|
||||
context_messages = []
|
||||
pending_user_message = None
|
||||
|
||||
captured = {}
|
||||
|
||||
def start_run(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
|
||||
monkeypatch.setattr(routes, "_start_run", start_run)
|
||||
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_snapshot = {
|
||||
"chat_backend": "gateway",
|
||||
"model": {"provider": "moa", "default": "@moa:moa-configured"},
|
||||
}
|
||||
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
|
||||
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
||||
|
||||
routes._handle_chat_start(
|
||||
_Handler(),
|
||||
{
|
||||
"session_id": "sess-gateway-non-moa",
|
||||
"message": "diagnose issue",
|
||||
"workspace": str(tmp_path),
|
||||
"model": "gpt-5.5",
|
||||
"model_provider": "openai-codex",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured["model_provider"] == "openai-codex"
|
||||
assert "moa_config" not in captured
|
||||
@@ -61,6 +61,7 @@ function installRuntimeHelpers() {
|
||||
"_filterRecoveryControlMessages",
|
||||
"_replaceMarkerOnlyAssistantWithStreamError",
|
||||
"_messageIdentityKey",
|
||||
"_isHistoricalAnchorActivityScene",
|
||||
"_carryForwardEphemeralTurnFields",
|
||||
"_isTerminalStreamErrorMarkerMessage",
|
||||
"_ensureSingleTerminalStreamErrorMarker",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,238 @@
|
||||
"""Sidebar stop must not settle the UI when /api/chat/cancel fails."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_function(src: str, name: str) -> str:
|
||||
m = re.search(rf"(?:async )?function {name}\s*\(", src)
|
||||
assert m, f"{name} not found in static/boot.js"
|
||||
brace_pos = src.index("{", m.end())
|
||||
depth = 1
|
||||
pos = brace_pos + 1
|
||||
while pos < len(src) and depth > 0:
|
||||
ch = src[pos]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
pos += 1
|
||||
return src[m.start():pos]
|
||||
|
||||
|
||||
CANCEL_SESSION_STREAM_SRC = _extract_function(BOOT_JS, "cancelSessionStream")
|
||||
COMPOSER_PRIMARY_ACTION_SRC = _extract_function(UI_JS, "handleComposerPrimaryAction")
|
||||
_TOAST_DEFAULT_NAME = "TOAST_" + "DEFAULT_MS"
|
||||
_TOAST_ERROR_DEFAULT_NAME = "TOAST_ERROR_" + "DEFAULT_MS"
|
||||
SHOW_TOAST_SRC = _extract_function(UI_JS, "show" + "Toast").replace(
|
||||
_TOAST_DEFAULT_NAME, "toast_default_ms"
|
||||
).replace(_TOAST_ERROR_DEFAULT_NAME, "toast_error_default_ms")
|
||||
|
||||
|
||||
def test_source_gates_sidebar_settle_on_http_success():
|
||||
assert "return false" in CANCEL_SESSION_STREAM_SRC
|
||||
assert "return true" in CANCEL_SESSION_STREAM_SRC
|
||||
assert "r.ok" in CANCEL_SESSION_STREAM_SRC, (
|
||||
"cancelSessionStream() must check the /api/chat/cancel HTTP status before "
|
||||
"closing local UI state"
|
||||
)
|
||||
assert "if(!respOk)returnfalse;" in "".join(CANCEL_SESSION_STREAM_SRC.split()), (
|
||||
"cancelSessionStream() must bail out on failed stop responses"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_callers_gate_success_toasts_on_cancel_result():
|
||||
compact_commands = "".join(COMMANDS_JS.split())
|
||||
compact_messages = "".join(MESSAGES_JS.split())
|
||||
compact_sessions = "".join(SESSIONS_JS.split())
|
||||
assert (
|
||||
"if(awaitcancelStream('slash-stop'))showToast(t('stream_stopped'));"
|
||||
"elseshowToast(t('cancel_failed'),null,'error');"
|
||||
) in compact_commands
|
||||
assert (
|
||||
"if(awaitcancelStream('slash-interrupt'))showToast(t('cmd_interrupt_confirm'),2000);"
|
||||
"elseshowToast(t('cancel_failed'),null,'error');"
|
||||
) in compact_commands
|
||||
assert (
|
||||
"if(awaitcancelStream('busy-interrupt'))showToast(t('busy_interrupt_confirm'),2000);"
|
||||
"elseshowToast(t('cancel_failed'),null,'error');"
|
||||
) in compact_messages
|
||||
assert (
|
||||
"if(awaitcancelSessionStream(session))showToast(t('stream_stopped'));"
|
||||
"elseshowToast(t('cancel_failed'),null,'error');"
|
||||
) in compact_sessions
|
||||
assert (
|
||||
"if(typeofcancelStream==='function'&&!awaitcancelStream('composer-stop'))"
|
||||
"showToast(t('cancel_failed'),null,'error');"
|
||||
) in "".join(UI_JS.split())
|
||||
|
||||
|
||||
_NODE_SCRIPT = r'''
|
||||
const M = {
|
||||
closeCalls: [],
|
||||
busyCalls: [],
|
||||
composerCalls: [],
|
||||
statusCalls: [],
|
||||
renderCalls: 0,
|
||||
clearCalls: [],
|
||||
approvalStops: 0,
|
||||
approvalHides: 0,
|
||||
clarifyStops: 0,
|
||||
clarifyHides: 0,
|
||||
fetchCalls: [],
|
||||
};
|
||||
|
||||
globalThis.INFLIGHT = { 'sid-1': { streamId: 'stream-1' } };
|
||||
globalThis.S = { activeStreamId: 'stream-1', session: { session_id: 'sid-1', active_stream_id: 'stream-1' } };
|
||||
globalThis.closeLiveStream = (...a) => M.closeCalls.push(a);
|
||||
globalThis.clearInflightState = (sid) => M.clearCalls.push(['clearInflightState', sid]);
|
||||
globalThis.clearInflight = () => M.clearCalls.push(['clearInflight']);
|
||||
globalThis.setBusy = (v) => M.busyCalls.push(v);
|
||||
globalThis.setComposerStatus = (v) => M.composerCalls.push(v);
|
||||
globalThis.setStatus = (v) => M.statusCalls.push(v);
|
||||
globalThis.stopApprovalPolling = () => M.approvalStops += 1;
|
||||
globalThis.hideApprovalCard = () => M.approvalHides += 1;
|
||||
globalThis.stopClarifyPolling = () => M.clarifyStops += 1;
|
||||
globalThis.hideClarifyCard = () => M.clarifyHides += 1;
|
||||
globalThis.renderSessionList = () => M.renderCalls += 1;
|
||||
globalThis._approvalSessionId = 'sid-1';
|
||||
globalThis._clarifySessionId = 'sid-1';
|
||||
globalThis.document = { baseURI: 'http://localhost:8787/' };
|
||||
globalThis.location = { href: 'http://localhost:8787/' };
|
||||
globalThis.fetch = (url, opts) => {
|
||||
M.fetchCalls.push({ url: String(url), opts });
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ ok: false, cancelled: false, stream_id: 'stream-1' }),
|
||||
});
|
||||
};
|
||||
|
||||
__CANCEL_SESSION_STREAM_SRC__
|
||||
|
||||
const session = { session_id: 'sid-1', active_stream_id: 'stream-1' };
|
||||
await cancelSessionStream(session);
|
||||
console.log(JSON.stringify({
|
||||
sessionActiveStreamId: session.active_stream_id,
|
||||
activeStreamId: globalThis.S.activeStreamId,
|
||||
closeCalls: M.closeCalls,
|
||||
busyCalls: M.busyCalls,
|
||||
composerCalls: M.composerCalls,
|
||||
renderCalls: M.renderCalls,
|
||||
clearCalls: M.clearCalls,
|
||||
approvalStops: M.approvalStops,
|
||||
approvalHides: M.approvalHides,
|
||||
clarifyStops: M.clarifyStops,
|
||||
clarifyHides: M.clarifyHides,
|
||||
fetchCalls: M.fetchCalls.length,
|
||||
}));
|
||||
'''
|
||||
|
||||
|
||||
def test_failed_sidebar_stop_keeps_local_state():
|
||||
script = _NODE_SCRIPT.replace("__CANCEL_SESSION_STREAM_SRC__", CANCEL_SESSION_STREAM_SRC)
|
||||
completed = subprocess.run(
|
||||
["node", "--input-type=module", "-e", script],
|
||||
cwd=str(REPO),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=30,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
|
||||
)
|
||||
result = json.loads(completed.stdout.splitlines()[-1])
|
||||
assert result["fetchCalls"] == 1
|
||||
assert result["sessionActiveStreamId"] == "stream-1"
|
||||
assert result["activeStreamId"] == "stream-1"
|
||||
assert result["closeCalls"] == []
|
||||
assert result["busyCalls"] == []
|
||||
assert result["composerCalls"] == []
|
||||
assert result["renderCalls"] == 0
|
||||
assert result["clearCalls"] == []
|
||||
assert result["approvalStops"] == 0
|
||||
assert result["approvalHides"] == 0
|
||||
assert result["clarifyStops"] == 0
|
||||
assert result["clarifyHides"] == 0
|
||||
|
||||
|
||||
def test_primary_composer_stop_renders_localized_error_and_preserves_success():
|
||||
cancel_key = "cancel_" + "failed"
|
||||
english = re.search(rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS)
|
||||
japanese = re.search(
|
||||
rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS[I18N_JS.index("ja:"):]
|
||||
)
|
||||
assert english and japanese
|
||||
english_message = english.group(1).replace("\\'", "'")
|
||||
japanese_message = japanese.group(1).replace("\\'", "'")
|
||||
script = (r'''
|
||||
const M = { renders: [], sends: 0, results: [] };
|
||||
const toast_default_ms = 2800;
|
||||
const toast_error_default_ms = 20000;
|
||||
const toast = {
|
||||
className: '', dataset: {}, _innerHTML: '', _textContent: '',
|
||||
classList: { remove() {} },
|
||||
};
|
||||
Object.defineProperty(toast, 'innerHTML', {
|
||||
get() { return this._innerHTML; },
|
||||
set(value) { this._innerHTML = String(value); this._textContent = ''; },
|
||||
});
|
||||
Object.defineProperty(toast, 'textContent', {
|
||||
get() { return this._textContent; },
|
||||
set(value) { this._textContent = String(value); this._innerHTML = ''; },
|
||||
});
|
||||
globalThis.$ = (id) => id === 'toast' ? toast : null;
|
||||
globalThis.esc = (value) => String(value);
|
||||
globalThis.clearToastDismissTimer = () => {};
|
||||
globalThis.setToastDismissTimer = (el, duration) => {
|
||||
M.renders.push({ message: el.dataset.toastMessage, className: el.className,
|
||||
duration, copy: el.innerHTML.includes('data-toast-' + 'copy="1"') });
|
||||
};
|
||||
globalThis.setTimeout = () => 0;
|
||||
globalThis.window = {};
|
||||
globalThis.S = {};
|
||||
globalThis.getComposerPrimaryAction = () => 'stop';
|
||||
__SHOW_TOAST_SRC__
|
||||
globalThis.send = () => { M.sends += 1; };
|
||||
globalThis.cancelStream = async () => M.cancelResult;
|
||||
__COMPOSER_PRIMARY_ACTION_SRC__
|
||||
for (const [message, result] of [[__ENGLISH__, false], [__JAPANESE__, false], ['unused', true]]) {
|
||||
M.cancelResult = result;
|
||||
globalThis.t = () => message;
|
||||
const before = M.renders.length;
|
||||
await handleComposerPrimaryAction();
|
||||
M.results.push({ result, rendered: M.renders.length - before, sends: M.sends });
|
||||
}
|
||||
console.log(JSON.stringify(M));
|
||||
'''.replace("__SHOW_TOAST_SRC__", SHOW_TOAST_SRC)
|
||||
.replace("__COMPOSER_PRIMARY_ACTION_SRC__", COMPOSER_PRIMARY_ACTION_SRC)
|
||||
.replace("__ENGLISH__", json.dumps(english_message))
|
||||
.replace("__JAPANESE__", json.dumps(japanese_message)))
|
||||
completed = subprocess.run(
|
||||
["node", "--input-type=module", "-e", script],
|
||||
cwd=str(REPO), capture_output=True, encoding="utf-8", timeout=30,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
|
||||
)
|
||||
result = json.loads(completed.stdout.splitlines()[-1])
|
||||
assert result["renders"] == [
|
||||
{"message": english_message, "className": "toast show error", "duration": 20000, "copy": True},
|
||||
{"message": japanese_message, "className": "toast show error", "duration": 20000, "copy": True},
|
||||
]
|
||||
assert result["results"] == [
|
||||
{"result": False, "rendered": 1, "sends": 0},
|
||||
{"result": False, "rendered": 1, "sends": 0},
|
||||
{"result": True, "rendered": 0, "sends": 0},
|
||||
]
|
||||
@@ -0,0 +1,276 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(os.environ.get("ISSUE6067_REPO_ROOT") or Path(__file__).resolve().parents[1])
|
||||
sys.path.insert(0, str(ROOT / "tests"))
|
||||
from _layout_helpers import assert_layout_sane
|
||||
|
||||
|
||||
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
EXPECT_VISIBLE = os.environ.get("ISSUE6067_EXPECT_VISIBLE", "1") != "0"
|
||||
HEADLESS = os.environ.get("ISSUE6067_HEADLESS", "1") != "0"
|
||||
SCREENSHOT_PATH = os.environ.get("ISSUE6067_SCREENSHOT_PATH")
|
||||
|
||||
ARTIFACT_CASES = [
|
||||
{"path": "users.py", "source": "patch", "display": "users.py", "name": "users.py", "head": "", "tail": ""},
|
||||
{"path": "src/api/routes/users.py", "source": "tool", "display": "src/api/routes/users.py", "name": "users.py", "head": "src/api/", "tail": "routes/"},
|
||||
{"path": "dir/foo.py", "source": "single", "display": "dir/foo.py", "name": "foo.py", "head": "", "tail": "dir/"},
|
||||
{"path": "src/project/app/api/routes/handlers/users.py", "source": "", "display": "src/project/app/api/routes/handlers/users.py", "name": "users.py", "head": "src/project/app/api/routes/", "tail": "handlers/"},
|
||||
{"path": "/file.txt", "source": "root", "display": "/file.txt", "name": "file.txt", "head": "/", "tail": ""},
|
||||
{"path": "dir/", "source": "trail", "display": "dir/", "name": "", "head": "", "tail": "dir/"},
|
||||
{"path": "", "source": "empty", "display": "", "name": "", "head": "", "tail": ""},
|
||||
{"path": "src//users.py", "source": "gap", "display": "src//users.py", "name": "users.py", "head": "src/", "tail": "/"},
|
||||
{"path": "src/lastIndexOf/users.py", "source": "literal", "display": "src/lastIndexOf/users.py", "name": "users.py", "head": "src/", "tail": "lastIndexOf/"},
|
||||
{"path": "/workspace/über/<unsafe>/routes/quote&.py", "source": "<source>&", "display": "über/<unsafe>/routes/quote&.py", "name": "quote&.py", "head": "über/<unsafe>/", "tail": "routes/"},
|
||||
{"path": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/summary.json", "source": "", "display": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/summary.json", "name": "summary.json", "head": "", "tail": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/"},
|
||||
]
|
||||
|
||||
|
||||
def _function(source, name):
|
||||
start = source.index(f"function {name}(")
|
||||
brace = source.index("{", start)
|
||||
depth = 0
|
||||
for pos in range(brace, len(source)):
|
||||
if source[pos] == "{":
|
||||
depth += 1
|
||||
elif source[pos] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start:pos + 1]
|
||||
raise AssertionError(f"could not extract {name}")
|
||||
|
||||
|
||||
def _locale_blocks():
|
||||
matches = list(re.finditer(r"^ ('[^']+'|[A-Za-z][A-Za-z0-9-]*): \{$", I18N_JS, re.MULTILINE))
|
||||
end = I18N_JS.index("\n};", matches[-1].start())
|
||||
return {
|
||||
match.group(1).strip("'"): I18N_JS[match.start() : (matches[index + 1].start() if index + 1 < len(matches) else end)]
|
||||
for index, match in enumerate(matches)
|
||||
}
|
||||
|
||||
|
||||
def _filename_visible(locator) -> bool:
|
||||
return locator.evaluate(
|
||||
"""node => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const text = range.getBoundingClientRect();
|
||||
const box = node.getBoundingClientRect();
|
||||
return text.left >= box.left - 1 && text.right <= box.right + 1;
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def _path_suffix_visible(locator, suffix: str) -> bool:
|
||||
return locator.evaluate(
|
||||
"""(node, suffix) => {
|
||||
const textNode = node.firstChild;
|
||||
if(!textNode || textNode.nodeType !== Node.TEXT_NODE) return false;
|
||||
const value = textNode.textContent || '';
|
||||
const start = value.lastIndexOf(suffix);
|
||||
if(start < 0) return false;
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, start);
|
||||
range.setEnd(textNode, start + suffix.length);
|
||||
const text = range.getBoundingClientRect();
|
||||
const box = node.getBoundingClientRect();
|
||||
return text.left >= box.left - 1 && text.right <= box.right + 1;
|
||||
}""",
|
||||
suffix,
|
||||
)
|
||||
|
||||
|
||||
def _directory_text_gap(locator) -> float:
|
||||
return locator.evaluate(
|
||||
"""node => {
|
||||
const head = node.querySelector('.workspace-artifact-directory-head');
|
||||
const tail = node.querySelector('.workspace-artifact-directory-tail');
|
||||
if(!head || !tail) return Number.POSITIVE_INFINITY;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(head);
|
||||
const text = range.getBoundingClientRect();
|
||||
const tailBox = tail.getBoundingClientRect();
|
||||
return tailBox.left - text.right;
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def test_issue6067_session_source_key_is_english_fallback_owned():
|
||||
locale_blocks = _locale_blocks()
|
||||
assert re.search(r"\bworkspace_artifact_source_session:\s*'session'", locale_blocks["en"])
|
||||
for locale, block in locale_blocks.items():
|
||||
if locale == "en":
|
||||
continue
|
||||
assert not re.search(r"\bworkspace_artifact_source_session:\s*'", block), (
|
||||
f"workspace_artifact_source_session must be absent from non-English locale {locale!r}"
|
||||
)
|
||||
assert "_locale[key] ?? LOCALES.en[key]" in I18N_JS
|
||||
|
||||
|
||||
def test_issue6067_artifact_filenames_remain_visible_across_artifact_widths():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception: # pragma: no cover - dependency missing path
|
||||
pytest.skip("playwright is unavailable; run manual local browser proof for issue #6067")
|
||||
|
||||
items = [{"path": case["path"], "source": case["source"]} for case in ARTIFACT_CASES]
|
||||
renderer = _function(WORKSPACE_JS, "renderSessionArtifacts")
|
||||
css = STYLE_CSS.replace("</style>", "")
|
||||
harness = f"""
|
||||
<style>{css}</style>
|
||||
<style>
|
||||
body {{
|
||||
margin: 0;
|
||||
background: #141327;
|
||||
color: #efe7dd;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}}
|
||||
.issue6067-proof-shell {{
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
}}
|
||||
.issue6067-proof-main {{
|
||||
flex: 1 1 auto;
|
||||
padding: 56px 48px;
|
||||
font-size: 24px;
|
||||
}}
|
||||
.issue6067-proof-panel {{
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
border-left: 1px solid rgba(255,255,255,.08);
|
||||
background: #17162b;
|
||||
}}
|
||||
.issue6067-proof-tabs {{
|
||||
display: flex;
|
||||
padding: 18px 16px 0;
|
||||
gap: 12px;
|
||||
}}
|
||||
.issue6067-proof-tab {{
|
||||
flex: 1 1 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
color: rgba(239,231,221,.72);
|
||||
}}
|
||||
.issue6067-proof-tab.active {{
|
||||
color: #efe7dd;
|
||||
border-color: rgba(255,255,255,.18);
|
||||
}}
|
||||
#workspaceArtifacts {{
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
</style>
|
||||
<div class="issue6067-proof-shell">
|
||||
<div class="issue6067-proof-main">Artifacts proof fixture for issue #6067</div>
|
||||
<div class="issue6067-proof-panel rightpanel" data-active-tab="artifacts">
|
||||
<div class="issue6067-proof-tabs">
|
||||
<div class="issue6067-proof-tab">Files</div>
|
||||
<div id="workspaceArtifactsTab" class="issue6067-proof-tab active">Artifacts</div>
|
||||
</div>
|
||||
<div id="workspaceArtifacts" class="workspace-artifacts"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="workspaceArtifactsCount"></span>
|
||||
<script>
|
||||
const S = {{session: {{workspace: '/workspace'}}, artifacts: {items!r}}};
|
||||
const opened = [];
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = value => String(value).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const t = key => key === 'workspace_artifact_source_session' ? 'session' : key;
|
||||
const collectSessionArtifacts = () => S.artifacts;
|
||||
const openArtifactPath = path => opened.push(path);
|
||||
{renderer}
|
||||
renderSessionArtifacts();
|
||||
</script>
|
||||
"""
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
headless=HEADLESS,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
page = browser.new_page(viewport={"width": 1024, "height": 600})
|
||||
page.set_content(harness)
|
||||
if SCREENSHOT_PATH:
|
||||
page.screenshot(path=SCREENSHOT_PATH)
|
||||
page.set_viewport_size({"width": 1024, "height": 1400})
|
||||
buttons = page.locator(".workspace-artifact-item")
|
||||
assert buttons.count() == len(ARTIFACT_CASES)
|
||||
assert page.locator(".workspace-artifact-meta").all_text_contents() == [
|
||||
"patch", "tool", "single", "session", "root", "trail", "empty", "gap", "literal", "<source>&", "session"
|
||||
]
|
||||
if EXPECT_VISIBLE:
|
||||
assert page.locator(".workspace-artifact-meta[data-i18n='workspace_artifact_source_session']").count() == 2
|
||||
for index, case in enumerate(ARTIFACT_CASES):
|
||||
button = buttons.nth(index)
|
||||
assert button.get_attribute("data-artifact-path") == case["path"]
|
||||
assert button.get_attribute("title") == case["display"]
|
||||
assert button.locator(".workspace-artifact-filename").inner_text() == case["name"]
|
||||
directory = button.locator(".workspace-artifact-directory")
|
||||
if case["head"] or case["tail"]:
|
||||
assert directory.count() == 1
|
||||
assert button.locator(".workspace-artifact-directory-head").inner_text() == case["head"]
|
||||
assert button.locator(".workspace-artifact-directory-tail").inner_text() == case["tail"]
|
||||
else:
|
||||
assert directory.count() == 0
|
||||
assert "<unsafe>" in buttons.nth(9).get_attribute("title")
|
||||
else:
|
||||
assert page.locator(".workspace-artifact-meta[data-i18n='workspace_artifact_source_session']").count() == 0
|
||||
assert page.locator(".workspace-artifact-path").all_text_contents() == [
|
||||
case["display"] for case in ARTIFACT_CASES
|
||||
]
|
||||
|
||||
for width in (180, 360, 519, 521):
|
||||
page.locator("#workspaceArtifacts").evaluate("(node, nextWidth) => node.style.width = nextWidth + 'px'", width)
|
||||
if EXPECT_VISIBLE:
|
||||
for index, case in enumerate(ARTIFACT_CASES):
|
||||
if case["name"]:
|
||||
assert _filename_visible(buttons.nth(index).locator(".workspace-artifact-filename"))
|
||||
assert buttons.nth(4).locator(".workspace-artifact-directory-head").inner_text() == "/"
|
||||
assert buttons.nth(7).locator(".workspace-artifact-directory-tail").inner_text() == "/"
|
||||
deep_head = buttons.nth(3).locator(".workspace-artifact-directory-head")
|
||||
assert deep_head.evaluate("node => node.scrollWidth >= node.clientWidth")
|
||||
if width in (360, 521):
|
||||
short_directory = buttons.nth(1).locator(".workspace-artifact-directory")
|
||||
short_head = buttons.nth(1).locator(".workspace-artifact-directory-head")
|
||||
assert short_head.evaluate("node => node.scrollWidth <= node.clientWidth + 1")
|
||||
assert _directory_text_gap(short_directory) <= 2
|
||||
elif width == 180:
|
||||
clipped_path = buttons.nth(1).locator(".workspace-artifact-path")
|
||||
assert not _path_suffix_visible(clipped_path, "users.py")
|
||||
assert_layout_sane(page, "#workspaceArtifacts", checks=["overlap", "container-escape", "degenerate", "raw-string"])
|
||||
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
page.locator(".issue6067-proof-panel").evaluate(
|
||||
"node => { node.style.width = '300px'; node.classList.add('mobile-open'); }"
|
||||
)
|
||||
page.locator("#workspaceArtifacts").evaluate("node => node.style.removeProperty('width')")
|
||||
long_row = buttons.nth(10)
|
||||
long_tail = long_row.locator(".workspace-artifact-directory-tail")
|
||||
for selector in ("#workspaceArtifacts", ".workspace-artifact-item"):
|
||||
assert page.locator(selector).nth(10 if selector != "#workspaceArtifacts" else 0).evaluate(
|
||||
"node => node.scrollWidth === node.clientWidth"
|
||||
)
|
||||
assert long_row.locator(".workspace-artifact-filename").inner_text() == "summary.json"
|
||||
assert _filename_visible(long_row.locator(".workspace-artifact-filename"))
|
||||
assert long_tail.evaluate(
|
||||
"node => node.scrollWidth > node.clientWidth && getComputedStyle(node).textOverflow === 'ellipsis'"
|
||||
)
|
||||
assert_layout_sane(page, "#workspaceArtifacts", checks=["overlap", "clip", "container-escape", "degenerate", "raw-string"])
|
||||
|
||||
for index in range(buttons.count()):
|
||||
buttons.nth(index).focus()
|
||||
assert page.evaluate("() => document.activeElement.classList.contains('workspace-artifact-item')")
|
||||
buttons.nth(index).click()
|
||||
assert page.evaluate("opened") == [case["path"] for case in ARTIFACT_CASES]
|
||||
browser.close()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Regression coverage for #6068 per-turn used-model footer instrumentation."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.models import Session
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
NODE = shutil.which("node")
|
||||
UI_JS_PATH = REPO / "static" / "ui.js"
|
||||
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
MODELS_PY = (REPO / "api" / "models.py").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run_node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
[NODE],
|
||||
input=source,
|
||||
cwd=str(REPO),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _eval_used_model_turn_chip_label_cases() -> dict:
|
||||
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {ui_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);
|
||||
}}
|
||||
function getModelLabel(modelId) {{
|
||||
return String(modelId || 'Unknown');
|
||||
}}
|
||||
eval(extractFunc('_compactComposerModelChipLabel'));
|
||||
eval(extractFunc('_usedModelTurnChipLabel'));
|
||||
eval(extractFunc('_gatewayProviderName'));
|
||||
eval(extractFunc('_gatewayRoutingLabel'));
|
||||
eval(extractFunc('_formatGatewayModelLabel'));
|
||||
const modelId = 'gpt-5-mini';
|
||||
const expectedPresent = _compactComposerModelChipLabel(modelId, getModelLabel(modelId));
|
||||
const cases = {{
|
||||
present: _usedModelTurnChipLabel({{ _usedModel: modelId }}),
|
||||
expectedPresent,
|
||||
suppressed: _usedModelTurnChipLabel({{
|
||||
_usedModel: modelId,
|
||||
_gatewayRouting: {{ used_model: 'deepseek-v3.2' }},
|
||||
}}),
|
||||
suppressedRoutingOnly: _usedModelTurnChipLabel({{
|
||||
_usedModel: modelId,
|
||||
_gatewayRouting: {{ used_provider: 'openrouter' }},
|
||||
}}),
|
||||
absent: _usedModelTurnChipLabel({{}}),
|
||||
nullMsg: _usedModelTurnChipLabel(null),
|
||||
gatewayFallback: _formatGatewayModelLabel(modelId, getModelLabel(modelId), {{ used_provider: 'openrouter' }}),
|
||||
}};
|
||||
console.log(JSON.stringify(cases));
|
||||
"""
|
||||
return json.loads(_run_node(source))
|
||||
|
||||
|
||||
def _eval_transparent_multi_segment_footer_cases() -> dict:
|
||||
"""Drive the transparent footer for a realistic multi-segment tool turn.
|
||||
|
||||
Turn shape: user -> assistant tool/activity segment (no metadata) -> tool
|
||||
result -> final assistant carrying _usedModel/_turnDuration. The footer must
|
||||
resolve metadata from the FINAL segment (not querySelector's first match) so
|
||||
the model label renders exactly once as .lf-model. Regression for the #6068
|
||||
round-2 gate where multi-segment turns dropped the label entirely.
|
||||
"""
|
||||
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {ui_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);
|
||||
}}
|
||||
function getModelLabel(modelId) {{ return String(modelId || 'Unknown'); }}
|
||||
function esc(s) {{ return String(s == null ? '' : s); }}
|
||||
function t() {{ return ''; }}
|
||||
// Multi-segment tool turn: idx 1 is the activity segment (no metadata),
|
||||
// idx 2 is the final answer that carries the stamped turn metadata.
|
||||
const S = {{ messages: [
|
||||
{{ role: 'user', content: 'hi' }},
|
||||
{{ role: 'assistant', content: '', _toolCalls: [{{}}] }},
|
||||
{{ role: 'assistant', content: 'answer', _usedModel: 'anthropic/claude-sonnet-4.5', _turnDuration: 1.2 }},
|
||||
] }};
|
||||
function FakeSeg(idx) {{ return {{ getAttribute(name) {{ return name === 'data-msg-idx' ? String(idx) : null; }} }}; }}
|
||||
const turn = {{ querySelectorAll(sel) {{ return [FakeSeg(1), FakeSeg(2)]; }} }};
|
||||
eval(extractFunc('_compactComposerModelChipLabel'));
|
||||
eval(extractFunc('_usedModelTurnChipLabel'));
|
||||
eval(extractFunc('_transparentTurnMetaMessage'));
|
||||
eval(extractFunc('_transparentTurnFooterHtml'));
|
||||
const picked = _transparentTurnMetaMessage(turn);
|
||||
const modelText = _usedModelTurnChipLabel(picked || {{}});
|
||||
const modelTitle = picked ? String(picked._usedModel || '') : '';
|
||||
const html = _transparentTurnFooterHtml('1.2s', modelText, '', '', 'Done', modelTitle);
|
||||
const lfModelCount = (html.match(/lf-model/g) || []).length;
|
||||
console.log(JSON.stringify({{
|
||||
pickedUsedModel: picked ? picked._usedModel : null,
|
||||
pickedContent: picked ? picked.content : null,
|
||||
modelText,
|
||||
lfModelCount,
|
||||
hasTitle: html.indexOf('title="anthropic/claude-sonnet-4.5"') >= 0,
|
||||
}}));
|
||||
"""
|
||||
return json.loads(_run_node(source))
|
||||
|
||||
|
||||
def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
|
||||
assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
|
||||
# The served model must be read from the agent AFTER the run — the agent
|
||||
# mutates agent.model when a fallback fires, so the pre-run resolved_model
|
||||
# would mis-attribute fallback turns.
|
||||
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" in STREAMING_PY
|
||||
assert "usage['used_model'] = _used_model" in STREAMING_PY
|
||||
|
||||
|
||||
def test_models_allowlist_round_trips_used_model_across_save_reload():
|
||||
assert '"_usedModel"' in MODELS_PY
|
||||
assert "_usedModel" in MODELS_PY.split("_SESSION_MESSAGE_DISPLAY_METADATA_KEYS")[1].split(")")[0]
|
||||
|
||||
session = Session(session_id="6068usedmodel", title="Used model")
|
||||
session.messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"_firstTokenMs": 250,
|
||||
"_usedModel": "gpt-5-mini",
|
||||
},
|
||||
]
|
||||
session.save()
|
||||
|
||||
reloaded = Session.load("6068usedmodel")
|
||||
assert reloaded.messages[-1]["_usedModel"] == "gpt-5-mini"
|
||||
assert reloaded.messages[-1]["_firstTokenMs"] == 250
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_used_model_turn_chip_label_renders_and_suppresses_gateway_duplicate():
|
||||
"""Behavioral footer chip cases from #6068 (not source-string greps)."""
|
||||
cases = _eval_used_model_turn_chip_label_cases()
|
||||
assert cases["present"] == cases["expectedPresent"]
|
||||
assert cases["present"] # non-empty label when _usedModel is set
|
||||
assert cases["suppressed"] == ""
|
||||
# Routing metadata WITHOUT used_model must also suppress the additive chip:
|
||||
# the gateway formatter owns the label (falling back to _usedModel), so a
|
||||
# provider-only routing payload must not render two model labels.
|
||||
assert cases["suppressedRoutingOnly"] == ""
|
||||
assert cases["absent"] == ""
|
||||
assert cases["nullMsg"] == ""
|
||||
# When routing omits used_model, the gateway label falls back to the
|
||||
# caller-provided model id (the settled footer passes msg._usedModel).
|
||||
assert cases["gatewayFallback"].startswith(cases["expectedPresent"])
|
||||
assert "via" in cases["gatewayFallback"]
|
||||
|
||||
|
||||
def test_settled_footer_wires_used_model_chip_in_dom_paths():
|
||||
assert "msg-used-model-inline" in UI_JS
|
||||
assert "_usedModelTurnChipLabel(msg)" in UI_JS
|
||||
# The settled footer must hand the served model to the gateway formatter as
|
||||
# its fallback, and skip the generic chip when the transparent turn footer
|
||||
# owns the model label (one model label per turn).
|
||||
assert "_formatGatewayModelLabel(String(msg._usedModel||'').trim()||(S.session&&S.session.model)||'', '', routing)" in UI_JS
|
||||
assert "_transparentFooterOwnsModel" in UI_JS
|
||||
assert ".msg-used-model-inline" in STYLE_CSS
|
||||
|
||||
|
||||
def test_settled_footer_orders_model_chip_after_duration():
|
||||
# Match the transparent footer order (elapsed · model · …): the duration
|
||||
# fragment must be pushed before the used-model fragment.
|
||||
footer_block = UI_JS.split("const usedModelText=_usedModelTurnChipLabel(msg);", 1)[1]
|
||||
duration_at = footer_block.index("duration.className='msg-duration-inline'")
|
||||
used_model_at = footer_block.index("usedModel.className='msg-used-model-inline'")
|
||||
assert duration_at < used_model_at
|
||||
|
||||
|
||||
def test_transparent_turn_footer_includes_model_between_duration_and_ttft():
|
||||
assert "function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText, modelTitle)" in UI_JS
|
||||
assert 'class="lf-model"' in UI_JS
|
||||
assert "modelText=_usedModelTurnChipLabel(msg)" in UI_JS
|
||||
assert ".transparent-turn-footer .lf-model" in STYLE_CSS
|
||||
|
||||
|
||||
def test_transparent_footer_reads_metadata_from_final_segment_not_first():
|
||||
# The transparent footer must resolve turn metadata via the dedicated
|
||||
# last→first segment scan, never querySelector's first-match, which would
|
||||
# read the activity segment and drop the model label on tool turns.
|
||||
assert "const msg=_transparentTurnMetaMessage(turn);" in UI_JS
|
||||
assert "function _transparentTurnMetaMessage(turn)" in UI_JS
|
||||
# The generic footer yields the model chip to the transparent footer.
|
||||
assert "_transparentFooterOwnsModel" in UI_JS
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_transparent_multi_segment_tool_turn_renders_exactly_one_model_label():
|
||||
"""#6068 gate round 2: multi-segment tool turns must show the model once."""
|
||||
cases = _eval_transparent_multi_segment_footer_cases()
|
||||
# Metadata resolved from the FINAL assistant segment, not the first.
|
||||
assert cases["pickedUsedModel"] == "anthropic/claude-sonnet-4.5"
|
||||
assert cases["pickedContent"] == "answer"
|
||||
assert cases["modelText"] # non-empty label
|
||||
# Exactly one .lf-model in the transparent footer (the generic chip is
|
||||
# suppressed by _transparentFooterOwnsModel), so no duplicate and no loss.
|
||||
assert cases["lfModelCount"] == 1
|
||||
# Non-blocking UX: full model id preserved in a hover title.
|
||||
assert cases["hasTitle"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Regression coverage for #6160: optional hiding of the full new-chat welcome panel."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INDEX = REPO_ROOT / "static" / "index.html"
|
||||
STYLE = REPO_ROOT / "static" / "style.css"
|
||||
PANELS = REPO_ROOT / "static" / "panels.js"
|
||||
BOOT = REPO_ROOT / "static" / "boot.js"
|
||||
I18N = REPO_ROOT / "static" / "i18n.js"
|
||||
CONFIG = REPO_ROOT / "api" / "config.py"
|
||||
|
||||
|
||||
def test_hide_welcome_panel_setting_is_default_off_and_boolean():
|
||||
src = CONFIG.read_text(encoding="utf-8")
|
||||
assert '"hide_empty_state_panel": False' in src
|
||||
assert '"hide_empty_state_panel",' in src
|
||||
|
||||
|
||||
def test_hide_welcome_panel_setting_persists_and_coerces_boolean(monkeypatch, tmp_path):
|
||||
import api.config as config
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
||||
|
||||
assert config.load_settings()["hide_empty_state_panel"] is False
|
||||
|
||||
saved = config.save_settings({"hide_empty_state_panel": True})
|
||||
assert saved["hide_empty_state_panel"] is True
|
||||
assert json.loads(settings_path.read_text(encoding="utf-8"))["hide_empty_state_panel"] is True
|
||||
|
||||
saved = config.save_settings({"hide_empty_state_panel": 0})
|
||||
assert saved["hide_empty_state_panel"] is False
|
||||
assert config.load_settings()["hide_empty_state_panel"] is False
|
||||
|
||||
|
||||
def test_preferences_expose_hide_welcome_panel_toggle():
|
||||
html = INDEX.read_text(encoding="utf-8")
|
||||
assert 'id="settingsHideEmptyStatePanel"' in html
|
||||
assert 'data-i18n="settings_label_hide_empty_state_panel"' in html
|
||||
assert 'data-i18n="settings_desc_hide_empty_state_panel"' in html
|
||||
|
||||
|
||||
def test_empty_state_has_non_important_full_panel_hide_hook():
|
||||
css = STYLE.read_text(encoding="utf-8")
|
||||
assert ".empty-state.no-welcome{display:none}" in css
|
||||
assert ".empty-state.no-welcome{display:none!important}" not in css
|
||||
|
||||
|
||||
def _boot_function(boot: str, name: str) -> str:
|
||||
start = boot.index(f"function {name}()")
|
||||
end = boot.index("\n}\n", start) + 2
|
||||
return boot[start:end]
|
||||
|
||||
|
||||
def test_boot_preference_hides_and_restores_the_panel_observably():
|
||||
node = shutil.which("node")
|
||||
assert node is not None, "node is required for the frontend behavior harness"
|
||||
|
||||
boot = BOOT.read_text(encoding="utf-8")
|
||||
function_src = _boot_function(boot, "applyEmptyStatePanelPref")
|
||||
harness = f"""
|
||||
const vm=require('vm');
|
||||
const classes=new Set();
|
||||
const emptyState={{classList:{{
|
||||
toggle(name, enabled){{ if(enabled) classes.add(name); else classes.delete(name); }},
|
||||
contains(name){{ return classes.has(name); }}
|
||||
}}}};
|
||||
const sandbox={{window:{{_hideEmptyStatePanel:false}}, $:(id)=>id==='emptyState'?emptyState:null}};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext({json.dumps(function_src)},sandbox);
|
||||
sandbox.applyEmptyStatePanelPref();
|
||||
if(emptyState.classList.contains('no-welcome')) throw new Error('default-off hid the panel');
|
||||
sandbox.window._hideEmptyStatePanel=true;
|
||||
sandbox.applyEmptyStatePanelPref();
|
||||
if(!emptyState.classList.contains('no-welcome')) throw new Error('enabled preference did not hide the panel');
|
||||
sandbox.window._hideEmptyStatePanel=false;
|
||||
sandbox.applyEmptyStatePanelPref();
|
||||
if(emptyState.classList.contains('no-welcome')) throw new Error('disabling preference did not restore the panel');
|
||||
"""
|
||||
subprocess.run([node, "-e", harness], check=True, capture_output=True, text=True)
|
||||
|
||||
assert "window._hideEmptyStatePanel=s.hide_empty_state_panel===true" in boot
|
||||
assert "window._hideEmptyStatePanel=false" in boot
|
||||
|
||||
|
||||
def test_restoring_panel_preserves_independent_hide_suggestions_preference():
|
||||
node = shutil.which("node")
|
||||
assert node is not None, "node is required for the frontend behavior harness"
|
||||
|
||||
boot = BOOT.read_text(encoding="utf-8")
|
||||
functions = "\n".join(
|
||||
_boot_function(boot, name)
|
||||
for name in ("applyEmptyStateSuggestionPref", "applyEmptyStatePanelPref")
|
||||
)
|
||||
harness = f"""
|
||||
const vm=require('vm');
|
||||
const classes=new Set();
|
||||
const emptyState={{classList:{{toggle(name,enabled){{if(enabled)classes.add(name);else classes.delete(name);}}}}}};
|
||||
const sandbox={{window:{{_hideEmptyStateSuggestions:true,_hideEmptyStatePanel:true}},$:(id)=>id==='emptyState'?emptyState:null}};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext({json.dumps(functions)},sandbox);
|
||||
sandbox.applyEmptyStateSuggestionPref();
|
||||
sandbox.applyEmptyStatePanelPref();
|
||||
if(!classes.has('no-suggestions')||!classes.has('no-welcome')) throw new Error('combined state was not applied');
|
||||
sandbox.window._hideEmptyStatePanel=false;
|
||||
sandbox.applyEmptyStatePanelPref();
|
||||
if(classes.has('no-welcome')) throw new Error('panel was not restored');
|
||||
if(!classes.has('no-suggestions')) throw new Error('restoring panel lost the suggestions preference');
|
||||
"""
|
||||
subprocess.run([node, "-e", harness], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_panels_round_trip_and_hot_apply_hide_welcome_panel():
|
||||
js = PANELS.read_text(encoding="utf-8")
|
||||
assert "payload.hide_empty_state_panel=hideEmptyStatePanelCb.checked;" in js
|
||||
assert "hideEmptyStatePanelCb.checked=settings.hide_empty_state_panel===true;" in js
|
||||
assert "window._hideEmptyStatePanel=hideEmptyStatePanelCb.checked;" in js
|
||||
assert "if(typeof applyEmptyStatePanelPref==='function') applyEmptyStatePanelPref();" in js
|
||||
|
||||
|
||||
def test_hide_welcome_panel_copy_covers_every_locale():
|
||||
js = I18N.read_text(encoding="utf-8")
|
||||
assert js.count("settings_label_hide_empty_state_panel:") == 15
|
||||
assert js.count("settings_desc_hide_empty_state_panel:") == 15
|
||||
assert "settings_label_hide_empty_state_panel: 'Ukryj panel powitalny nowej konwersacji'" in js
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Regression tests for the public-share local-media embedding boundary (#6174).
|
||||
|
||||
These assert the hard security boundary of ``_embed_share_media`` /
|
||||
``build_share_snapshot``: only image files that resolve INSIDE an allowed root
|
||||
may be embedded as inline base64; every other reference (path traversal,
|
||||
symlink escape, absolute-path-outside, ``file://``, non-image, SVG,
|
||||
extension-spoofed, oversize, empty-allowed-roots) must degrade to a static
|
||||
placeholder so no local file bytes leak into a public share.
|
||||
|
||||
Added as the committed regression net for PR #6319 (the reviewer's own
|
||||
adversarial reproduction of the boundary).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api import shares
|
||||
|
||||
|
||||
def _write_png(path: Path) -> None:
|
||||
# Minimal valid PNG magic header + filler.
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sandbox(tmp_path: Path):
|
||||
"""A workspace (allowed root) plus a secret dir OUTSIDE it."""
|
||||
ws = tmp_path / "workspace"
|
||||
ws.mkdir()
|
||||
outside = tmp_path / "secret"
|
||||
outside.mkdir()
|
||||
(outside / "creds.txt").write_text("SECRET-API-KEY-DO-NOT-LEAK")
|
||||
_write_png(ws / "ok.png")
|
||||
# A .png that is actually a script (extension spoof).
|
||||
(ws / "evil.png").write_bytes(b"#!/bin/sh\necho pwned\n")
|
||||
# An SVG inside the workspace (text-bearing, must NOT embed).
|
||||
(ws / "pic.svg").write_bytes(
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
)
|
||||
return {"ws": ws, "outside": outside}
|
||||
|
||||
|
||||
def _embed(text: str, roots):
|
||||
return shares._embed_share_media(text, allowed_roots=tuple(roots))
|
||||
|
||||
|
||||
def test_valid_workspace_image_is_embedded(sandbox):
|
||||
out = _embed("MEDIA:ok.png", [sandbox["ws"]])
|
||||
assert "base64," in out
|
||||
assert "data:image/png" in out
|
||||
assert shares._PLACEHOLDER not in out
|
||||
|
||||
|
||||
def test_relative_path_traversal_is_blocked(sandbox):
|
||||
out = _embed("MEDIA:../secret/creds.txt", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
assert "SECRET-API-KEY" not in out
|
||||
|
||||
|
||||
def test_absolute_path_outside_allowed_roots_is_blocked(sandbox):
|
||||
creds = sandbox["outside"] / "creds.txt"
|
||||
out = _embed(f"MEDIA:{creds}", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
assert "SECRET-API-KEY" not in out
|
||||
|
||||
|
||||
def test_absolute_path_to_valid_image_inside_root_embeds(sandbox):
|
||||
ok = sandbox["ws"] / "ok.png"
|
||||
out = _embed(f"MEDIA:{ok}", [sandbox["ws"]])
|
||||
assert "base64," in out
|
||||
|
||||
|
||||
def test_file_uri_is_always_rejected(sandbox):
|
||||
creds = sandbox["outside"] / "creds.txt"
|
||||
out = _embed(f"MEDIA:file://{creds}", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
assert "SECRET-API-KEY" not in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink creation is restricted on Windows CI")
|
||||
def test_symlink_escaping_allowed_root_is_blocked(sandbox):
|
||||
link = sandbox["ws"] / "link.png"
|
||||
os.symlink(sandbox["outside"] / "creds.txt", link)
|
||||
out = _embed("MEDIA:link.png", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
assert "SECRET-API-KEY" not in out
|
||||
|
||||
|
||||
def test_extension_spoofed_non_image_is_blocked(sandbox):
|
||||
# evil.png is a shell script; magic-byte validation must reject it.
|
||||
out = _embed("MEDIA:evil.png", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
|
||||
|
||||
def test_svg_is_never_embedded(sandbox):
|
||||
out = _embed("MEDIA:pic.svg", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
|
||||
|
||||
def test_oversize_image_is_blocked(sandbox):
|
||||
big = sandbox["ws"] / "big.png"
|
||||
big.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * (shares._SHARE_EMBED_MAX_BYTES + 1))
|
||||
out = _embed("MEDIA:big.png", [sandbox["ws"]])
|
||||
assert out == shares._PLACEHOLDER
|
||||
|
||||
|
||||
def test_empty_allowed_roots_fails_closed(sandbox):
|
||||
ok = sandbox["ws"] / "ok.png"
|
||||
assert _embed("MEDIA:ok.png", []) == shares._PLACEHOLDER
|
||||
assert _embed(f"MEDIA:{ok}", []) == shares._PLACEHOLDER
|
||||
|
||||
|
||||
def test_public_https_url_passes_through_unchanged(sandbox):
|
||||
text = "MEDIA:https://example.com/a.png"
|
||||
assert _embed(text, [sandbox["ws"]]) == text
|
||||
|
||||
|
||||
def test_sanitize_message_embeds_inside_allowed_root(sandbox):
|
||||
msg = {"role": "assistant", "content": "see MEDIA:ok.png"}
|
||||
out = shares._sanitize_message(msg, allowed_roots=(sandbox["ws"],))
|
||||
assert out is not None
|
||||
assert "base64," in out["content"]
|
||||
|
||||
|
||||
def test_sanitize_message_blocks_traversal_and_never_leaks(sandbox):
|
||||
msg = {"role": "assistant", "content": "see MEDIA:../secret/creds.txt"}
|
||||
out = shares._sanitize_message(msg, allowed_roots=(sandbox["ws"],))
|
||||
assert out is not None
|
||||
assert "SECRET-API-KEY" not in out["content"]
|
||||
assert shares._PLACEHOLDER in out["content"]
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Regression coverage for #6220 historical tool transcript ownership.
|
||||
|
||||
Only an unambiguous ``assistant.tool_calls[].id`` ->
|
||||
``role=tool.tool_call_id`` turn may move from the legacy settled renderer into
|
||||
an Assistant Turn Anchor scene. Ambiguous or richer historical shapes stay on
|
||||
the compatibility path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
ANCHOR_JS = ROOT / "static" / "assistant_turn_anchors.js"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
_EXTRACT_FUNC_JS = r"""
|
||||
function extractFunc(name){
|
||||
const start = src.indexOf('function ' + name);
|
||||
if(start === -1) throw new Error(name + ' not found');
|
||||
const params = src.indexOf('(', start);
|
||||
let depth = 0, close = -1;
|
||||
for(let i=params; i<src.length; i++){
|
||||
if(src[i] === '(') depth++;
|
||||
else if(src[i] === ')'){
|
||||
depth--;
|
||||
if(depth === 0){ close = i; break; }
|
||||
}
|
||||
}
|
||||
const brace = src.indexOf('{', close);
|
||||
depth = 0;
|
||||
for(let i=brace; i<src.length; i++){
|
||||
if(src[i] === '{') depth++;
|
||||
else if(src[i] === '}'){
|
||||
depth--;
|
||||
if(depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(name + ' body did not close');
|
||||
}
|
||||
""".strip()
|
||||
|
||||
|
||||
def _run_hydration(messages: list[dict], *, repeat: bool = False, throw_helper: bool = False) -> dict:
|
||||
assert NODE, "node is required for Anchor hydration behavior tests"
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const src = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8');
|
||||
{_EXTRACT_FUNC_JS}
|
||||
|
||||
const sandbox = {{console}};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'),
|
||||
sandbox,
|
||||
{{filename:'assistant_turn_anchors.js'}}
|
||||
);
|
||||
sandbox._redactToolTargetLabel = value => String(value)
|
||||
.replace(/([A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD)[A-Za-z0-9_]*\\s*=\\s*)(?:"[^"]*"|'[^']*'|\\S+)/gi, '$1[redacted]');
|
||||
|
||||
for(const name of [
|
||||
'_clipCliToolSnippet',
|
||||
'_cliToolResultText',
|
||||
'_cliLooksLikePatchDiff',
|
||||
'_cliToolResultSnippet',
|
||||
'_prefixedCliDiffLines',
|
||||
'_firstOwnedValue',
|
||||
'_cliPatchSnippetFromArgs',
|
||||
'_cliToolCardSnippet',
|
||||
'_cliToolCardHasDiffSnippet',
|
||||
'_anchorSceneToolCallFromRow',
|
||||
'_toolArgsSnapshot',
|
||||
'_assistantReasoningPayloadText',
|
||||
'_idLinkedHistoricalMessageText',
|
||||
'_idLinkedHistoricalMessageHasVisibleText',
|
||||
'_idLinkedHistoricalMessageRef',
|
||||
'_idLinkedHistoricalToolArguments',
|
||||
'_idLinkedHistoricalToolResultRaw',
|
||||
'_idLinkedHistoricalRedactSnippet',
|
||||
'_idLinkedHistoricalHasVisibleSidecar',
|
||||
'_idLinkedHistoricalTurnScene',
|
||||
'_hydrateIdLinkedHistoricalToolScenes',
|
||||
]){{
|
||||
const fn = src.indexOf('function ' + name) >= 0 ? extractFunc(name) : null;
|
||||
if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}});
|
||||
}}
|
||||
|
||||
if({str(throw_helper).lower()}){{
|
||||
vm.runInContext(
|
||||
"_toolArgsSnapshot = function(){{ throw new Error('synthetic event build failure'); }}",
|
||||
sandbox
|
||||
);
|
||||
}}
|
||||
|
||||
const messages = {json.dumps(messages)};
|
||||
sandbox.messages = messages;
|
||||
const first = vm.runInContext(
|
||||
"_hydrateIdLinkedHistoricalToolScenes(messages, {{sessionId:'sid-6220', mode:'compact_worklog'}})",
|
||||
sandbox
|
||||
);
|
||||
const afterFirst = JSON.stringify(messages);
|
||||
const second = {str(repeat).lower()}
|
||||
? vm.runInContext(
|
||||
"_hydrateIdLinkedHistoricalToolScenes(messages, {{sessionId:'sid-6220', mode:'compact_worklog'}})",
|
||||
sandbox
|
||||
)
|
||||
: null;
|
||||
const owners = messages
|
||||
.map((message, index) => message && message._anchor_activity_scene ? {{index, scene:message._anchor_activity_scene}} : null)
|
||||
.filter(Boolean);
|
||||
sandbox.owners = owners;
|
||||
const toolCards = vm.runInContext(
|
||||
"owners.map(owner => owner.scene.activity_rows.map(row => _anchorSceneToolCallFromRow(row, {{settled:true}})))",
|
||||
sandbox
|
||||
);
|
||||
console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringify(messages), owners, toolCards}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _run_carry_forward_then_hydration(messages: list[dict]) -> dict:
|
||||
assert NODE, "node is required for Anchor hydration behavior tests"
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const uiSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8');
|
||||
const messagesSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'messages.js'))}, 'utf8');
|
||||
let src = uiSrc;
|
||||
{_EXTRACT_FUNC_JS}
|
||||
|
||||
function extractFuncFrom(source, name){{
|
||||
const previous = src;
|
||||
src = source;
|
||||
try{{ return extractFunc(name); }}
|
||||
finally{{ src = previous; }}
|
||||
}}
|
||||
|
||||
const sandbox = {{console}};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox._EPHEMERAL_TURN_FIELDS = [
|
||||
'_turnUsage',
|
||||
'_turnDuration',
|
||||
'_turnTps',
|
||||
'_gatewayRouting',
|
||||
'_statusCard',
|
||||
'_anchor_stream_id',
|
||||
'_anchor_activity_scene',
|
||||
];
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'),
|
||||
sandbox,
|
||||
{{filename:'assistant_turn_anchors.js'}}
|
||||
);
|
||||
sandbox._redactToolTargetLabel = value => String(value);
|
||||
|
||||
for(const name of [
|
||||
'_clipCliToolSnippet',
|
||||
'_cliToolResultText',
|
||||
'_cliLooksLikePatchDiff',
|
||||
'_cliToolResultSnippet',
|
||||
'_prefixedCliDiffLines',
|
||||
'_firstOwnedValue',
|
||||
'_cliPatchSnippetFromArgs',
|
||||
'_cliToolCardSnippet',
|
||||
'_cliToolCardHasDiffSnippet',
|
||||
'_anchorSceneToolCallFromRow',
|
||||
'_toolArgsSnapshot',
|
||||
'_assistantReasoningPayloadText',
|
||||
'_idLinkedHistoricalMessageText',
|
||||
'_idLinkedHistoricalMessageHasVisibleText',
|
||||
'_idLinkedHistoricalMessageRef',
|
||||
'_idLinkedHistoricalToolArguments',
|
||||
'_idLinkedHistoricalToolResultRaw',
|
||||
'_idLinkedHistoricalRedactSnippet',
|
||||
'_idLinkedHistoricalHasVisibleSidecar',
|
||||
'_idLinkedHistoricalTurnScene',
|
||||
'_hydrateIdLinkedHistoricalToolScenes',
|
||||
]){{
|
||||
const fn = uiSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(uiSrc, name) : null;
|
||||
if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}});
|
||||
}}
|
||||
for(const name of [
|
||||
'_messageIdentityKey',
|
||||
'_isHistoricalAnchorActivityScene',
|
||||
'_carryForwardEphemeralTurnFields',
|
||||
]){{
|
||||
const fn = messagesSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(messagesSrc, name) : null;
|
||||
if(fn) vm.runInContext(fn, sandbox, {{filename:'messages.js:' + name}});
|
||||
}}
|
||||
|
||||
const original = {json.dumps(messages)};
|
||||
const prev = JSON.parse(JSON.stringify(original));
|
||||
sandbox.prev = prev;
|
||||
vm.runInContext(
|
||||
"_hydrateIdLinkedHistoricalToolScenes(prev, {{sessionId:'sid-6220', mode:'compact_worklog'}})",
|
||||
sandbox
|
||||
);
|
||||
const next = JSON.parse(JSON.stringify(original));
|
||||
sandbox.next = next;
|
||||
vm.runInContext("_carryForwardEphemeralTurnFields(prev, next)", sandbox);
|
||||
const carried = next
|
||||
.map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null)
|
||||
.filter(Boolean);
|
||||
const hydrated = vm.runInContext(
|
||||
"_hydrateIdLinkedHistoricalToolScenes(next, {{sessionId:'sid-6220', mode:'compact_worklog'}})",
|
||||
sandbox
|
||||
);
|
||||
const owners = next
|
||||
.map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null)
|
||||
.filter(Boolean);
|
||||
console.log(JSON.stringify({{carried, hydrated, owners}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _linked_turn(call_id: str = "call-1") -> list[dict]:
|
||||
return [
|
||||
{"role": "user", "content": "Inspect the workspace"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": '{"command":"pwd"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"content": '{"output":"/workspace"}',
|
||||
},
|
||||
{"role": "assistant", "content": "The workspace is `/workspace`."},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently():
|
||||
data = _run_hydration(_linked_turn(), repeat=True)
|
||||
|
||||
assert data["first"] == 1
|
||||
assert data["second"] == 0
|
||||
assert data["unchanged"] is True
|
||||
assert [owner["index"] for owner in data["owners"]] == [3]
|
||||
|
||||
scene = data["owners"][0]["scene"]
|
||||
assert scene["version"] == "activity_scene_v1"
|
||||
assert scene["final_answer"] == "The workspace is `/workspace`."
|
||||
assert scene["final_message_ref"] == "raw_idx:3"
|
||||
assert scene["identity"]["session_id"] == "sid-6220"
|
||||
assert scene["identity"]["source_message_refs"] == ["raw_idx:1", "raw_idx:3"]
|
||||
assert len(scene["activity_rows"]) == 1
|
||||
|
||||
row = scene["activity_rows"][0]
|
||||
assert row["role"] == "tool"
|
||||
assert row["source_event_type"] == "tool_complete"
|
||||
assert row["tool_call_id"] == "call-1"
|
||||
assert row["tool"]["name"] == "terminal"
|
||||
assert row["tool"]["args"] == {"command": "pwd"}
|
||||
assert row["tool"]["snippet"] == "/workspace"
|
||||
assert row["tool"]["done"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_id_linked_historical_final_answer_preserves_raw_string_whitespace():
|
||||
messages = _linked_turn()
|
||||
messages[-1]["content"] = " indented code\n"
|
||||
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 1
|
||||
assert data["owners"][0]["scene"]["final_answer"] == " indented code\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_id_linked_historical_final_answer_joins_text_parts_like_renderer():
|
||||
messages = _linked_turn()
|
||||
messages[-1]["content"] = [
|
||||
{"type": "text", "text": "First paragraph."},
|
||||
{"type": "input_text", "text": "ignored input"},
|
||||
{"type": "text", "text": "Second paragraph."},
|
||||
]
|
||||
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 1
|
||||
assert data["owners"][0]["scene"]["final_answer"] == "First paragraph.\nSecond paragraph."
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_id_linked_historical_multi_tool_turn_preserves_declaration_order():
|
||||
messages = [
|
||||
{"role": "user", "content": "Inspect both files"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-first",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": '{"command":"pwd"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call-second",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": '{"path":"README.md"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-second", "content": "readme body"},
|
||||
{"role": "tool", "tool_call_id": "call-first", "content": "/workspace"},
|
||||
{"role": "assistant", "content": "Both checks completed."},
|
||||
]
|
||||
|
||||
data = _run_hydration(messages, repeat=True)
|
||||
|
||||
assert data["first"] == 1
|
||||
assert data["second"] == 0
|
||||
rows = data["owners"][0]["scene"]["activity_rows"]
|
||||
assert [row["tool_call_id"] for row in rows] == ["call-first", "call-second"]
|
||||
assert [row["tool"]["name"] for row in rows] == ["terminal", "read_file"]
|
||||
assert [row["tool"]["snippet"] for row in rows] == ["/workspace", "readme body"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
@pytest.mark.parametrize(
|
||||
"messages",
|
||||
[
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "call-1", "function": {"name": "one", "arguments": "{}"}},
|
||||
{"id": "call-1", "function": {"name": "two", "arguments": "{}"}},
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "first"},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "second"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": "I will inspect it first.", "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "reasoning": "Need to inspect it.", "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "user", "content": "What happened?"},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": [{"type": "tool_use", "id": "tool-1", "name": "terminal", "input": {}}]},
|
||||
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": "ok"}]},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{not-json"}}]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}],
|
||||
"_partial_tool_calls": [{"name": "terminal"}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messages):
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 0
|
||||
assert data["owners"] == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
@pytest.mark.parametrize("content", [
|
||||
{"stdout": "...", "env": "API_KEY=result-secret-123"},
|
||||
[{"stdout": "ok"}],
|
||||
None,
|
||||
])
|
||||
def test_non_string_historical_tool_results_keep_legacy_ownership(content):
|
||||
messages = _linked_turn()
|
||||
messages[2]["content"] = content
|
||||
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 0
|
||||
assert data["owners"] == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_string_historical_tool_result_is_redacted_before_anchor_scene():
|
||||
messages = _linked_turn()
|
||||
messages[2]["content"] = '{"output":"API_KEY=result-secret-123"}'
|
||||
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 1
|
||||
row = data["owners"][0]["scene"]["activity_rows"][0]
|
||||
assert "result-secret-123" not in json.dumps(row)
|
||||
assert "API_KEY=[redacted]" in row["tool"]["snippet"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
@pytest.mark.parametrize(
|
||||
"messages",
|
||||
[
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "system", "content": "visible system status"},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "developer", "content": "visible developer note"},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "function", "name": "legacy_func", "content": "legacy function output"},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]},
|
||||
{"role": "assistant", "content": None, "tool_calls": []},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"attachments": [{"name": "evidence.txt"}],
|
||||
"tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"_statusCard": {"title": "Visible status"},
|
||||
"tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
[
|
||||
{"role": "user", "content": "Run it"},
|
||||
{"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": ""}}]},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": "ok"},
|
||||
{"role": "assistant", "content": "Done"},
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_non_whitelisted_intermediate_records_keep_legacy_ownership(messages):
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 0
|
||||
assert data["owners"] == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_diff_historical_tool_result_preserves_diff_card_projection():
|
||||
messages = _linked_turn()
|
||||
messages[2]["content"] = "diff --git a/demo.txt b/demo.txt\n@@\n-old\n+new\n"
|
||||
|
||||
data = _run_hydration(messages)
|
||||
|
||||
assert data["first"] == 1
|
||||
row = data["owners"][0]["scene"]["activity_rows"][0]
|
||||
assert row["payload"]["is_diff"] is True
|
||||
assert row["tool"]["is_diff"] is True
|
||||
assert data["toolCards"][0][0]["is_diff"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_event_building_helper_exceptions_keep_legacy_ownership():
|
||||
data = _run_hydration(_linked_turn(), throw_helper=True)
|
||||
|
||||
assert data["first"] == 0
|
||||
assert data["owners"] == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_reused_ids_in_adjacent_complete_turns_remain_turn_scoped():
|
||||
data = _run_hydration(_linked_turn("call-reused") + _linked_turn("call-reused"))
|
||||
|
||||
assert data["first"] == 2
|
||||
assert [owner["index"] for owner in data["owners"]] == [3, 7]
|
||||
assert [owner["scene"]["activity_rows"][0]["tool_call_id"] for owner in data["owners"]] == ["call-reused", "call-reused"]
|
||||
assert data["owners"][0]["scene"]["identity"]["turn_id"] != data["owners"][1]["scene"]["identity"]["turn_id"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests")
|
||||
def test_duplicate_final_answers_rebuild_historical_scenes_after_refresh():
|
||||
first_turn = _linked_turn("call-first")
|
||||
second_turn = _linked_turn("call-second")
|
||||
first_turn[1]["tool_calls"][0]["function"]["name"] = "first_tool"
|
||||
first_turn[2]["content"] = "first result"
|
||||
first_turn[3]["content"] = "Done"
|
||||
second_turn[1]["tool_calls"][0]["function"]["name"] = "second_tool"
|
||||
second_turn[2]["content"] = "second result"
|
||||
second_turn[3]["content"] = "Done"
|
||||
|
||||
data = _run_carry_forward_then_hydration(first_turn + second_turn)
|
||||
|
||||
assert data["carried"] == []
|
||||
assert data["hydrated"] == 2
|
||||
assert [owner["index"] for owner in data["owners"]] == [3, 7]
|
||||
assert [owner["tools"] for owner in data["owners"]] == [["call-first"], ["call-second"]]
|
||||
assert data["owners"][0]["turn_id"] != data["owners"][1]["turn_id"]
|
||||
|
||||
|
||||
def test_render_messages_hydrates_before_visibility_and_fallback_ownership_scan():
|
||||
start = UI_JS.index("function renderMessages(options)")
|
||||
body = UI_JS[start : UI_JS.index("\nfunction ", start + 1)]
|
||||
|
||||
hydrate = "_hydrateIdLinkedHistoricalToolScenes(S.messages"
|
||||
visible = "const visWithIdx=_getVisibleMessagesWithIdx();"
|
||||
fallback = "const anchorOwnedAssistantRawIdxs=new Set();"
|
||||
|
||||
assert hydrate in body
|
||||
assert body.index(hydrate) < body.index(visible) < body.index(fallback)
|
||||
assert "if(!S.busy" in body[: body.index(visible)]
|
||||
@@ -14,7 +14,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -25,3 +25,30 @@ def test_kanban_columns_render_scrollable_card_lists():
|
||||
assert "overflow-y:auto" in rule
|
||||
assert "overscroll-behavior:contain" in rule
|
||||
assert "scrollbar-gutter:stable" in rule
|
||||
|
||||
|
||||
def test_kanban_consolidated_board_fills_viewport_height():
|
||||
"""The consolidated (single-board) view must stretch to fill the viewport
|
||||
instead of leaving empty space below the columns on desktop.
|
||||
|
||||
Both the board element and its column bodies drop their capped heights and
|
||||
take height:100% so the columns extend to the bottom of the board wrap.
|
||||
"""
|
||||
board_rule = _css_rule(".kanban-board.kanban-board-consolidated")
|
||||
assert "height:100%" in board_rule
|
||||
|
||||
body_rule = _css_rule(".kanban-board-consolidated .kanban-column-body")
|
||||
assert "height:100%" in body_rule
|
||||
assert "max-height:unset" in body_rule
|
||||
# The consolidated view owns its own full-height scroll container, so it opts
|
||||
# out of the scroll-chaining lock the capped lane view uses.
|
||||
assert "overscroll-behavior:auto" in body_rule
|
||||
|
||||
|
||||
def test_kanban_consolidated_class_toggled_by_lane_mode():
|
||||
"""The consolidated-height CSS only takes effect when the board element
|
||||
carries the `kanban-board-consolidated` class, which the renderer adds only
|
||||
in the single-board (non-lanes) view. Pin the wiring so a render refactor
|
||||
can't silently strip the class and reintroduce the empty-space regression.
|
||||
"""
|
||||
assert "classList.toggle('kanban-board-consolidated'" in PANELS_JS
|
||||
@@ -815,6 +815,27 @@ def test_kanban_review_feedback_static_ui_fixes_exist():
|
||||
assert "kanban-stats-grid" in PANELS
|
||||
|
||||
|
||||
def test_kanban_modal_mobile_responsive_css():
|
||||
"""On narrow phones (<=640px) a tall kanban modal must stay reachable: the
|
||||
overlay scrolls (overflow-y:auto) and safe-centers its content
|
||||
(align-items:safe center) so an overflowing modal is never clipped above
|
||||
the fold where its top can't be scrolled back into view."""
|
||||
# There are several `.kanban-modal-overlay{...}` rules (skin override, base
|
||||
# desktop, and the mobile <=640px override, which is last in the file).
|
||||
# Match against COMPACT_STYLE — whitespace-stripped, like the sibling CSS
|
||||
# tests — and take the last rule to isolate the mobile override, instead of
|
||||
# depending on brittle @media-block bracket matching.
|
||||
overlay_rules = re.findall(r"\.kanban-modal-overlay\{([^}]*)\}", COMPACT_STYLE)
|
||||
assert overlay_rules, "no .kanban-modal-overlay rule found in style.css"
|
||||
mobile = overlay_rules[-1]
|
||||
assert "overflow-y:auto" in mobile, f"mobile overlay must be scrollable. Got: {mobile}"
|
||||
# `align-items:safe center` compacts to `align-items:safecenter`.
|
||||
assert "align-items:safecenter" in mobile, (
|
||||
f"mobile overlay must safe-center its content so an overflowing modal "
|
||||
f"is never clipped above the fold. Got: {mobile}"
|
||||
)
|
||||
|
||||
|
||||
def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback():
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Stable run identity regressions for live Anchor scene projection/hydration."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def test_runtime_journal_snapshot_preserves_run_id_separately_from_stream_id(
|
||||
monkeypatch,
|
||||
):
|
||||
from api import routes
|
||||
|
||||
session_id = "session-stable-run"
|
||||
run_id = "run-stable-1"
|
||||
stream_id = "stream-transport-1"
|
||||
events = [
|
||||
{
|
||||
"event": "token",
|
||||
"seq": 1,
|
||||
"event_id": f"{run_id}:1",
|
||||
"run_id": run_id,
|
||||
"created_at": 1.0,
|
||||
"payload": {"text": "working"},
|
||||
},
|
||||
{
|
||||
"event": "reasoning",
|
||||
"seq": 2,
|
||||
"event_id": f"{run_id}:2",
|
||||
"run_id": run_id,
|
||||
"created_at": 2.0,
|
||||
"payload": {"text": "checking"},
|
||||
},
|
||||
{
|
||||
"event": "tool",
|
||||
"seq": 3,
|
||||
"event_id": f"{run_id}:3",
|
||||
"run_id": run_id,
|
||||
"created_at": 3.0,
|
||||
"payload": {"name": "terminal", "tid": "call-1"},
|
||||
},
|
||||
{
|
||||
"event": "tool_complete",
|
||||
"seq": 4,
|
||||
"event_id": f"{run_id}:4",
|
||||
"run_id": run_id,
|
||||
"created_at": 4.0,
|
||||
"payload": {"name": "terminal", "tid": "call-1", "preview": "ok"},
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda lookup_id: {
|
||||
"session_id": session_id,
|
||||
# The current summary is keyed by the legacy lookup id, while the
|
||||
# durable event envelope already carries the stable run identity.
|
||||
"run_id": stream_id,
|
||||
"stream_id": stream_id,
|
||||
"last_seq": 4,
|
||||
"last_event_id": f"{run_id}:4",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda loaded_session_id, lookup_id: {"events": events},
|
||||
)
|
||||
|
||||
snapshot = routes._run_journal_live_snapshot(stream_id)
|
||||
scene = snapshot["anchor_activity_scene"]
|
||||
|
||||
assert snapshot["stream_id"] == stream_id
|
||||
assert scene["identity"]["run_id"] == run_id
|
||||
assert scene["identity"]["stream_id"] == stream_id
|
||||
assert scene["activity_rows"]
|
||||
assert [row["role"] for row in scene["activity_rows"]] == [
|
||||
"prose",
|
||||
"thinking",
|
||||
"tool",
|
||||
]
|
||||
assert {row["run_id"] for row in scene["activity_rows"]} == {run_id}
|
||||
assert {row["stream_id"] for row in scene["activity_rows"]} == {stream_id}
|
||||
assert {row["identity"]["run_id"] for row in scene["activity_rows"]} == {run_id}
|
||||
assert {row["identity"]["stream_id"] for row in scene["activity_rows"]} == {
|
||||
stream_id
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_journal_lifecycle_shell_preserves_stable_run_id(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
run_id = "run-stable-lifecycle"
|
||||
stream_id = "stream-transport-lifecycle"
|
||||
event = {
|
||||
"event": "metering",
|
||||
"seq": 1,
|
||||
"event_id": f"{run_id}:1",
|
||||
"run_id": run_id,
|
||||
"payload": {},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda lookup_id: {
|
||||
"session_id": "session-stable-lifecycle",
|
||||
"run_id": stream_id,
|
||||
"stream_id": stream_id,
|
||||
"last_seq": 1,
|
||||
"last_event_id": f"{run_id}:1",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda loaded_session_id, lookup_id: {"events": [event]},
|
||||
)
|
||||
|
||||
snapshot = routes._run_journal_live_snapshot(stream_id)
|
||||
scene = snapshot["anchor_activity_scene"]
|
||||
row = scene["activity_rows"][0]
|
||||
|
||||
assert row["role"] == "lifecycle"
|
||||
assert row["run_id"] == run_id
|
||||
assert row["stream_id"] == stream_id
|
||||
assert row["identity"]["run_id"] == run_id
|
||||
assert row["identity"]["stream_id"] == stream_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event_run_id", "event_id"),
|
||||
[
|
||||
({"bad": "id"}, None),
|
||||
("run-conflicting-envelope", "run-conflicting-event-id:1"),
|
||||
],
|
||||
)
|
||||
def test_runtime_journal_malformed_envelope_run_id_falls_back_to_transport_cursor(
|
||||
monkeypatch,
|
||||
event_run_id,
|
||||
event_id,
|
||||
):
|
||||
from api import routes
|
||||
|
||||
stream_id = "stream-current-1"
|
||||
event = {
|
||||
"event": "token",
|
||||
"seq": 1,
|
||||
"run_id": event_run_id,
|
||||
"payload": {"text": "hello"},
|
||||
}
|
||||
if event_id is not None:
|
||||
event["event_id"] = event_id
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"find_run_summary",
|
||||
lambda lookup_id: {
|
||||
"session_id": "session-malformed-envelope",
|
||||
"run_id": stream_id,
|
||||
"stream_id": stream_id,
|
||||
"last_seq": 1,
|
||||
"last_event_id": f"{stream_id}:1",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_run_events",
|
||||
lambda loaded_session_id, lookup_id: {"events": [event]},
|
||||
)
|
||||
|
||||
snapshot = routes._run_journal_live_snapshot(stream_id)
|
||||
scene = snapshot["anchor_activity_scene"]
|
||||
row = scene["activity_rows"][0]
|
||||
|
||||
assert scene["identity"]["run_id"] == stream_id
|
||||
assert row["run_id"] == stream_id
|
||||
assert row["identity"]["run_id"] == stream_id
|
||||
assert snapshot["last_event_id"] == f"{stream_id}:1"
|
||||
assert (
|
||||
routes._parse_run_journal_after_seq(
|
||||
{"after_event_id": [snapshot["last_event_id"]]},
|
||||
stream_id,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
NODE is None, reason="node is required for browser hydration regression coverage"
|
||||
)
|
||||
def test_browser_scene_hydration_prefers_stable_run_id_with_legacy_stream_fallback():
|
||||
messages_path = ROOT / "static" / "messages.js"
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
const src=fs.readFileSync({json.dumps(str(messages_path))},'utf8');
|
||||
function extractFunc(name){{
|
||||
const start=src.indexOf('function '+name);
|
||||
if(start===-1) throw new Error(name+' not found');
|
||||
const params=src.indexOf('(',start);
|
||||
let depth=0,close=-1;
|
||||
for(let i=params;i<src.length;i+=1){{
|
||||
if(src[i]==='(') depth+=1;
|
||||
else if(src[i]===')'){{
|
||||
depth-=1;
|
||||
if(depth===0){{ close=i; break; }}
|
||||
}}
|
||||
}}
|
||||
const brace=src.indexOf('{{',close);
|
||||
depth=0;
|
||||
for(let i=brace;i<src.length;i+=1){{
|
||||
if(src[i]==='{{') depth+=1;
|
||||
else if(src[i]==='}}'){{
|
||||
depth-=1;
|
||||
if(depth===0) return src.slice(start,i+1);
|
||||
}}
|
||||
}}
|
||||
throw new Error(name+' body did not close');
|
||||
}}
|
||||
|
||||
const activeSid='session-stable-run';
|
||||
const streamId='stream-transport-1';
|
||||
let _anchorShadowWarned=false;
|
||||
let _anchorRegistry={{anchor:{{identity:{{run_id:null,stream_id:streamId}}}}}};
|
||||
const applied=[];
|
||||
const _anchorApi={{
|
||||
applyAssistantTurnAnchorSourceEvent(registry,event,context){{
|
||||
applied.push({{event,context}});
|
||||
registry.anchor.identity.run_id=event.run_id||context.run_id||null;
|
||||
registry.anchor.identity.stream_id=event.stream_id||context.stream_id||null;
|
||||
return event;
|
||||
}},
|
||||
}};
|
||||
eval(extractFunc('_sourceEventTypeForSnapshotAnchorRow'));
|
||||
eval(extractFunc('_hydrateAnchorRegistryFromActivityScene'));
|
||||
|
||||
function scene(identity,rowId){{
|
||||
return {{
|
||||
version:'activity_scene_v1',
|
||||
identity,
|
||||
activity_rows:[{{
|
||||
row_id:rowId,
|
||||
role:'prose',
|
||||
kind:'process_prose',
|
||||
source_event_type:'token',
|
||||
payload:{{text:'working'}},
|
||||
text:'working',
|
||||
}}],
|
||||
}};
|
||||
}}
|
||||
|
||||
const stableHydrated=_hydrateAnchorRegistryFromActivityScene(scene({{
|
||||
run_id:'run-stable-1',
|
||||
stream_id:streamId,
|
||||
}},'stable-row'));
|
||||
const stable=applied[0];
|
||||
|
||||
_anchorRegistry={{anchor:{{identity:{{run_id:null,stream_id:streamId}}}}}};
|
||||
const legacyHydrated=_hydrateAnchorRegistryFromActivityScene(scene({{
|
||||
stream_id:streamId,
|
||||
}},'legacy-row'));
|
||||
const legacy=applied[1];
|
||||
|
||||
const sceneScopedStreamId='stream-scene-scoped';
|
||||
_anchorRegistry={{anchor:{{identity:{{run_id:null,stream_id:sceneScopedStreamId}}}}}};
|
||||
const fallbackHydrated=_hydrateAnchorRegistryFromActivityScene(scene({{
|
||||
stream_id:sceneScopedStreamId,
|
||||
}},null));
|
||||
const fallback=applied[2];
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
stableHydrated,
|
||||
legacyHydrated,
|
||||
fallbackHydrated,
|
||||
stable,
|
||||
legacy,
|
||||
fallback,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["stableHydrated"] is True
|
||||
assert data["stable"]["event"]["run_id"] == "run-stable-1"
|
||||
assert data["stable"]["event"]["stream_id"] == "stream-transport-1"
|
||||
assert data["stable"]["context"]["run_id"] == "run-stable-1"
|
||||
assert data["stable"]["context"]["stream_id"] == "stream-transport-1"
|
||||
assert data["legacyHydrated"] is True
|
||||
assert data["legacy"]["event"]["run_id"] == "stream-transport-1"
|
||||
assert data["legacy"]["context"]["run_id"] == "stream-transport-1"
|
||||
assert data["fallbackHydrated"] is True
|
||||
assert data["fallback"]["event"]["local_id"] == "snapshot:stream-scene-scoped:0"
|
||||
@@ -948,9 +948,16 @@ def test_settled_anchor_scene_does_not_persist_running_live_activity_rows():
|
||||
assert "const hasSettledThinking=_anchorSceneMessageRowsHaveThinking(messageRows);" in complete
|
||||
assert "row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking);" in complete
|
||||
assert "String(value||'').startsWith('live-')" in live_identity
|
||||
assert "const hasStreamOwner=!!(row.stream_id||row.run_id||identity.stream_id||identity.run_id);" in live_identity
|
||||
assert "const hasAssistantMessageIndex=group.assistant_msg_idx!==undefined&&group.assistant_msg_idx!==null;" in live_identity
|
||||
assert "return hasStreamOwner&&!hasAssistantMessageIndex;" in live_identity
|
||||
assert "String(row.status||'').toLowerCase()!=='running'" in settle_live
|
||||
assert "if(row.role==='thinking'&&hasSettledThinking) return null;" in settle_live
|
||||
assert "return {...row,status:'completed'};" in settle_live
|
||||
assert "const sealed={...row,status:'completed'};" in settle_live
|
||||
assert "sealed.payload={...row.payload,status:'completed'};" in settle_live
|
||||
assert "if(row.role==='tool') sealed.payload.done=true;" in settle_live
|
||||
assert "sealed.tool={...row.tool,done:true};" in settle_live
|
||||
assert "return sealed;" in settle_live
|
||||
|
||||
|
||||
def test_settled_anchor_scene_separates_final_answer_from_activity_rows():
|
||||
|
||||
@@ -941,6 +941,127 @@ class TestMediaEndpointIntegration(unittest.TestCase):
|
||||
finally:
|
||||
pathlib.Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_ts_artifact_served_as_text_plain_with_attachment(self):
|
||||
""".ts file via /api/media must have text/plain Content-Type,
|
||||
Content-Disposition: attachment, and X-Content-Type-Options: nosniff.
|
||||
Regression for PR #6372 — ensures narrow MIME_MAP fix is live."""
|
||||
ts_bytes = b"const x: number = 42;\nconsole.log(x);\n"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".ts", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False
|
||||
) as f:
|
||||
f.write(ts_bytes)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
body, status, headers = self._get(
|
||||
f"/api/media?path={urllib.parse.quote(tmp_path)}"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
ct = headers.get("Content-Type", "")
|
||||
self.assertIn(
|
||||
"text/plain", ct,
|
||||
f"Expected text/plain Content-Type for .ts, got {ct}",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"text/javascript", ct,
|
||||
f".ts must NOT be served as text/javascript, got {ct}",
|
||||
)
|
||||
disp = headers.get("Content-Disposition", "")
|
||||
self.assertIn(
|
||||
"attachment", disp,
|
||||
f"Expected attachment Content-Disposition for .ts, got {disp}",
|
||||
)
|
||||
self.assertEqual(
|
||||
headers.get("X-Content-Type-Options"),
|
||||
"nosniff",
|
||||
"X-Content-Type-Options: nosniff must be set on media responses",
|
||||
)
|
||||
self.assertEqual(body, ts_bytes)
|
||||
finally:
|
||||
pathlib.Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_tsx_artifact_served_as_text_plain_with_attachment(self):
|
||||
""".tsx file via /api/media must also have text/plain Content-Type
|
||||
and attachment disposition. Regression for PR #6372."""
|
||||
tsx_bytes = b"const App: React.FC = () => <div>Hello</div>;\n"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".tsx", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False
|
||||
) as f:
|
||||
f.write(tsx_bytes)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
body, status, headers = self._get(
|
||||
f"/api/media?path={urllib.parse.quote(tmp_path)}"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
ct = headers.get("Content-Type", "")
|
||||
self.assertIn(
|
||||
"text/plain", ct,
|
||||
f"Expected text/plain Content-Type for .tsx, got {ct}",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"text/javascript", ct,
|
||||
f".tsx must NOT be served as text/javascript, got {ct}",
|
||||
)
|
||||
disp = headers.get("Content-Disposition", "")
|
||||
self.assertIn(
|
||||
"attachment", disp,
|
||||
f"Expected attachment Content-Disposition for .tsx, got {disp}",
|
||||
)
|
||||
self.assertEqual(
|
||||
headers.get("X-Content-Type-Options"),
|
||||
"nosniff",
|
||||
)
|
||||
self.assertEqual(body, tsx_bytes)
|
||||
finally:
|
||||
pathlib.Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_file_raw_js_not_served_as_inline_executable(self):
|
||||
""".js files served via /api/file/raw must NOT get text/javascript
|
||||
Content-Type — they should fall through to application/octet-stream
|
||||
since MIME_MAP intentionally omits .js. Regression for PR #6372."""
|
||||
# Create a session so we can use /api/file/raw
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
BASE + "/api/session/new",
|
||||
data=b"{}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
sess_data = json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
sess_data = json.loads(e.read())
|
||||
self.fail(f"Cannot create test session: {sess_data}")
|
||||
sid = sess_data.get("session_id") or sess_data.get("session", {}).get("session_id", "")
|
||||
self.assertTrue(sid, f"No session_id in response: {sess_data}")
|
||||
ws = pathlib.Path(
|
||||
sess_data.get("workspace") or sess_data.get("session", {}).get("workspace", "")
|
||||
)
|
||||
self.assertTrue(str(ws), f"No workspace in response: {sess_data}")
|
||||
|
||||
js_bytes = b"const x = 1;\n"
|
||||
js_file = ws / "exploit_test.js"
|
||||
try:
|
||||
js_file.write_bytes(js_bytes)
|
||||
|
||||
encoded = urllib.parse.quote("exploit_test.js")
|
||||
body, status, headers = self._get(
|
||||
f"/api/file/raw?session_id={sid}&path={encoded}"
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
ct = headers.get("Content-Type", "")
|
||||
self.assertNotIn(
|
||||
"text/javascript", ct,
|
||||
f".js via /api/file/raw must NOT be text/javascript, got {ct}",
|
||||
)
|
||||
# Without a .js MIME_MAP entry, it falls back to application/octet-stream
|
||||
self.assertEqual(
|
||||
ct, "application/octet-stream",
|
||||
f"Expected application/octet-stream for unmapped .js, got {ct}",
|
||||
)
|
||||
self.assertEqual(body, js_bytes)
|
||||
finally:
|
||||
js_file.unlink(missing_ok=True)
|
||||
|
||||
def test_health_check_still_works(self):
|
||||
"""Sanity: server is up and /health works."""
|
||||
body, status, _ = self._get("/health")
|
||||
|
||||
@@ -121,6 +121,60 @@ def test_cross_provider_routes_through_openrouter():
|
||||
assert base_url is None # openrouter uses its own endpoint
|
||||
|
||||
|
||||
def test_cross_provider_routes_through_openrouter_with_alias_prefix():
|
||||
"""A model whose prefix is a _PROVIDER_ALIASES entry (not itself a
|
||||
_PROVIDER_MODELS key) but resolves to a canonical provider key still
|
||||
routes through the cross-provider OpenRouter branch.
|
||||
|
||||
Regression test for #6131 — before the fix, ``z-ai/glm-5.2`` with
|
||||
config provider ``anthropic`` would fall through and return the stale
|
||||
config provider because ``z-ai`` was checked directly against
|
||||
``_PROVIDER_MODELS`` instead of being resolved through
|
||||
``_PROVIDER_ALIASES`` first.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'z-ai/glm-5.2', provider='anthropic',
|
||||
)
|
||||
assert model == 'z-ai/glm-5.2'
|
||||
assert provider == 'openrouter'
|
||||
assert base_url is None # openrouter uses its own endpoint
|
||||
|
||||
|
||||
def test_cross_provider_openrouter_qwen_selection_not_stale_routed():
|
||||
"""An OpenRouter Qwen selection under a different config provider must
|
||||
still route through openrouter — NOT inherit the stale config provider.
|
||||
|
||||
Regression guard for #6131's fix: ``qwen`` canonicalises to ``qwen``
|
||||
(a _PROVIDER_MODELS key), but its hermes_cli *alias* target is
|
||||
``alibaba`` (NOT a _PROVIDER_MODELS key). An earlier fix that resolved
|
||||
through _PROVIDER_ALIASES instead of the idempotent
|
||||
_canonicalise_provider_id would have mapped ``qwen`` → ``alibaba``,
|
||||
missed the membership check, and wrongly returned the stale provider.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'qwen/qwen3-max', provider='anthropic',
|
||||
)
|
||||
assert model == 'qwen/qwen3-max'
|
||||
assert provider == 'openrouter'
|
||||
assert base_url is None
|
||||
|
||||
|
||||
def test_alias_prefix_matching_active_provider_not_cross_routed():
|
||||
"""A namespaced model whose prefix is an *alias of its own active
|
||||
provider* must NOT be cross-routed to openrouter.
|
||||
|
||||
``z-ai/glm-5.2`` under provider ``zai`` — the prefix ``z-ai`` and the
|
||||
config provider ``zai`` canonicalise to the SAME id (``zai``), so this
|
||||
is a same-provider selection, not a cross-provider one. The fix compares
|
||||
canonical prefix against canonical config provider (not the raw prefix)
|
||||
so the inequality guard correctly suppresses the openrouter cross-route.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'z-ai/glm-5.2', provider='zai',
|
||||
)
|
||||
assert provider != 'openrouter'
|
||||
|
||||
|
||||
# ── Bare model names ─────────────────────────────────────────────────────
|
||||
|
||||
def test_bare_model_uses_config_provider():
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Regression coverage for large native image redaction overhead.
|
||||
|
||||
Native multimodal messages store raster images as base64 data URIs. Those
|
||||
opaque image bytes must not be sent through the text credential redactor: a
|
||||
large image can randomly contain one of the cheap prefilter's markers and then
|
||||
pay for every regex pass in the agent redactor.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from api import helpers
|
||||
from api.session_export_html import render_session_html
|
||||
|
||||
|
||||
_FAKE_AWS_KEY = "AKIATESTFAKEKEY12345"
|
||||
_ONE_PIXEL_JPEG = base64.b64decode(
|
||||
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k="
|
||||
)
|
||||
_ONE_PIXEL_GIF = base64.b64decode(
|
||||
"R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
)
|
||||
_ONE_PIXEL_WEBP = base64.b64decode(
|
||||
"UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuU"
|
||||
)
|
||||
|
||||
|
||||
def _one_pixel_bmp() -> bytes:
|
||||
pixel_data = b"\x00\x00\x00\x00" # BGR black + row padding
|
||||
file_size = 54 + len(pixel_data)
|
||||
file_header = b"BM" + struct.pack("<IHHI", file_size, 0, 0, 54)
|
||||
dib_header = struct.pack(
|
||||
"<IiiHHIIiiII",
|
||||
40,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
24,
|
||||
0,
|
||||
len(pixel_data),
|
||||
2835,
|
||||
2835,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
return file_header + dib_header + pixel_data
|
||||
|
||||
|
||||
def _png_chunk(kind: bytes, data: bytes) -> bytes:
|
||||
crc = zlib.crc32(kind + data) & 0xFFFFFFFF
|
||||
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc)
|
||||
|
||||
|
||||
def _valid_png_bytes_with_sensitive_marker(*, extra_bytes: int = 0) -> bytes:
|
||||
"""Build a real 1x1 RGBA PNG whose base64 contains an ``AKIA`` quartet."""
|
||||
marker_bytes = base64.b64decode("AKIA")
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0)
|
||||
# The first data byte starts at absolute offset 41. One byte of padding
|
||||
# aligns marker_bytes to a base64 quantum, preserving the literal AKIA
|
||||
# quartet that exercises the redaction prefilter.
|
||||
marker_chunk = _png_chunk(
|
||||
b"raNd",
|
||||
b"\x00" + marker_bytes + (b"image-bytes" * 32) + (b"x" * extra_bytes),
|
||||
)
|
||||
idat = _png_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00\x00"))
|
||||
prefix = b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", ihdr) + marker_chunk + idat
|
||||
# Make the complete PNG length a multiple of three. This lets the
|
||||
# maintainer-supplied post-IEND attack append decoded bytes whose standard
|
||||
# base64 representation remains the literal fake credential.
|
||||
for pad_len in range(3):
|
||||
raw = prefix + _png_chunk(b"paDd", b"x" * pad_len) + _png_chunk(b"IEND", b"")
|
||||
if len(raw) % 3 == 0:
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
assert "AKIA" in encoded
|
||||
return raw
|
||||
raise AssertionError("could not align synthetic PNG")
|
||||
|
||||
|
||||
def _png_data_uri_with_sensitive_marker() -> str:
|
||||
raw = _valid_png_bytes_with_sensitive_marker()
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
assert "AKIA" in encoded
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def _png_data_uri_with_post_iend_credential() -> str:
|
||||
raw = _valid_png_bytes_with_sensitive_marker()
|
||||
suffix = base64.b64decode(_FAKE_AWS_KEY, validate=True)
|
||||
encoded = base64.b64encode(raw + suffix).decode("ascii")
|
||||
assert encoded.endswith(_FAKE_AWS_KEY)
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
|
||||
|
||||
def test_native_raster_data_uri_bypasses_text_redactor(monkeypatch):
|
||||
uri = _png_data_uri_with_sensitive_marker()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or "unexpected-redaction",
|
||||
)
|
||||
|
||||
content_part = {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": uri, "detail": "auto"},
|
||||
}
|
||||
|
||||
redacted = helpers.redact_session_data(
|
||||
{"messages": [{"role": "user", "content": [content_part]}]}
|
||||
)
|
||||
assert redacted["messages"][0]["content"][0] == content_part
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_multimegabyte_native_raster_still_bypasses_text_redactor(monkeypatch):
|
||||
raw = _valid_png_bytes_with_sensitive_marker(extra_bytes=1_000_000)
|
||||
uri = f"data:image/png;base64,{base64.b64encode(raw).decode('ascii')}"
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or "unexpected-redaction",
|
||||
)
|
||||
|
||||
result = helpers.redact_session_data(
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "image_url", "image_url": {"url": uri}}],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert result["messages"][0]["content"][0]["image_url"]["url"] == uri
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_native_raster_data_uri_accepts_uppercase_mime(monkeypatch):
|
||||
uri = _png_data_uri_with_sensitive_marker().replace(
|
||||
"data:image/png",
|
||||
"data:image/PNG",
|
||||
1,
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or "unexpected-redaction",
|
||||
)
|
||||
|
||||
content_part = {"type": "image_url", "image_url": {"url": uri}}
|
||||
|
||||
redacted = helpers.redact_session_data(
|
||||
{"messages": [{"role": "user", "content": [content_part]}]}
|
||||
)
|
||||
assert redacted["messages"][0]["content"][0] == content_part
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_raster_data_uri_outside_image_part_keeps_security_boundary(monkeypatch):
|
||||
uri = _png_data_uri_with_sensitive_marker()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or "redacted",
|
||||
)
|
||||
|
||||
assert helpers._redact_value({"content": uri}, _enabled=True) == {
|
||||
"content": "redacted",
|
||||
}
|
||||
assert calls == [uri]
|
||||
|
||||
|
||||
def test_non_raster_image_part_keeps_security_boundary(monkeypatch):
|
||||
uri = "data:image/svg+xml;base64," + base64.b64encode(
|
||||
b"<svg> " + base64.b64decode("AKIA") + b" sensitive text</svg>"
|
||||
).decode("ascii")
|
||||
assert "AKIA" in uri
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or "redacted",
|
||||
)
|
||||
|
||||
result = helpers._redact_value(
|
||||
{"type": "image_url", "image_url": {"url": uri}},
|
||||
_enabled=True,
|
||||
)
|
||||
|
||||
assert result["image_url"]["url"] == "redacted"
|
||||
assert calls == [uri]
|
||||
|
||||
|
||||
def test_declared_raster_with_wrong_magic_keeps_security_boundary(monkeypatch):
|
||||
encoded = base64.b64encode(b"not-a-png" + base64.b64decode("AKIA")).decode("ascii")
|
||||
uri = f"data:image/png;base64,{encoded}"
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or "redacted",
|
||||
)
|
||||
|
||||
result = helpers._redact_value(
|
||||
{"type": "image_url", "image_url": {"url": uri}},
|
||||
_enabled=True,
|
||||
)
|
||||
|
||||
assert result["image_url"]["url"] == "redacted"
|
||||
assert calls == [uri]
|
||||
|
||||
|
||||
def test_post_iend_credential_falls_through_to_text_redaction(monkeypatch):
|
||||
uri = _png_data_uri_with_post_iend_credential()
|
||||
calls = []
|
||||
|
||||
def redact(text):
|
||||
calls.append(text)
|
||||
return text.replace(_FAKE_AWS_KEY, "[REDACTED]")
|
||||
|
||||
monkeypatch.setattr(helpers, "_redact_fn_cached", redact)
|
||||
content_part = {"type": "image_url", "image_url": {"url": uri}}
|
||||
|
||||
result = helpers.redact_session_data(
|
||||
{"messages": [{"role": "user", "content": [content_part]}]}
|
||||
)
|
||||
|
||||
assert _FAKE_AWS_KEY not in result["messages"][0]["content"][0]["image_url"]["url"]
|
||||
assert calls == [uri]
|
||||
assert helpers._is_native_raster_data_uri(uri) is False
|
||||
|
||||
|
||||
def test_post_iend_credential_is_removed_by_real_redactor():
|
||||
uri = _png_data_uri_with_post_iend_credential()
|
||||
result = helpers.redact_session_data(
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "image_url", "image_url": {"url": uri}}],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert _FAKE_AWS_KEY not in json.dumps(result)
|
||||
|
||||
|
||||
def test_image_exemption_is_scoped_to_direct_message_content_parts(monkeypatch):
|
||||
valid_uri = _png_data_uri_with_sensitive_marker()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: calls.append(text) or text.replace("AKIA", "[REDACTED]"),
|
||||
)
|
||||
image_shaped = {"type": "image_url", "image_url": {"url": valid_uri}}
|
||||
session = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
image_shaped,
|
||||
{"type": "text", "text": f"sibling={_FAKE_AWS_KEY}"},
|
||||
],
|
||||
"metadata": image_shaped,
|
||||
},
|
||||
{"role": "assistant", "content": [image_shaped]},
|
||||
],
|
||||
"tool_calls": [image_shaped],
|
||||
"todo_state": {"image": image_shaped},
|
||||
"runtime_journal_snapshot": {"messages": [image_shaped]},
|
||||
}
|
||||
|
||||
result = helpers.redact_session_data(session)
|
||||
|
||||
# Only the canonical messages[*].content[*] image URL stays opaque.
|
||||
assert result["messages"][0]["content"][0]["image_url"]["url"] == valid_uri
|
||||
assert _FAKE_AWS_KEY not in result["messages"][0]["content"][1]["text"]
|
||||
assert "AKIA" not in result["messages"][0]["metadata"]["image_url"]["url"]
|
||||
assert "AKIA" not in result["messages"][1]["content"][0]["image_url"]["url"]
|
||||
assert "AKIA" not in result["tool_calls"][0]["image_url"]["url"]
|
||||
assert "AKIA" not in result["todo_state"]["image"]["image_url"]["url"]
|
||||
assert "AKIA" not in result["runtime_journal_snapshot"]["messages"][0]["image_url"]["url"]
|
||||
assert calls
|
||||
|
||||
|
||||
def test_crafted_image_is_redacted_from_json_html_and_journal(monkeypatch):
|
||||
uri = _png_data_uri_with_post_iend_credential()
|
||||
monkeypatch.setattr(
|
||||
helpers,
|
||||
"_redact_fn_cached",
|
||||
lambda text: text.replace(_FAKE_AWS_KEY, "[REDACTED]"),
|
||||
)
|
||||
crafted_part = {"type": "image_url", "image_url": {"url": uri}}
|
||||
safe = helpers.redact_session_data(
|
||||
{
|
||||
"title": "security regression",
|
||||
"messages": [{"role": "user", "content": [crafted_part]}],
|
||||
"runtime_journal_snapshot": {
|
||||
"messages": [crafted_part],
|
||||
"persisted_payload": uri,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
json_export = json.dumps(safe)
|
||||
html_export = render_session_html(safe)
|
||||
assert _FAKE_AWS_KEY not in json_export
|
||||
assert _FAKE_AWS_KEY not in html_export
|
||||
assert _FAKE_AWS_KEY not in json.dumps(safe["runtime_journal_snapshot"])
|
||||
|
||||
|
||||
def test_uncertain_base64_forms_fail_closed():
|
||||
valid_uri = _png_data_uri_with_sensitive_marker()
|
||||
header, payload = valid_uri.split(",", 1)
|
||||
hostile = [
|
||||
f"{header},{payload[:16]}\n{payload[16:]}",
|
||||
f"{valid_uri}?token={_FAKE_AWS_KEY}",
|
||||
f"{valid_uri} {_FAKE_AWS_KEY}",
|
||||
f"{header},{payload[:-1]}",
|
||||
f"{header},{payload}=A",
|
||||
]
|
||||
|
||||
assert all(not helpers._is_native_raster_data_uri(value) for value in hostile)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mime,raw",
|
||||
[
|
||||
("png", _valid_png_bytes_with_sensitive_marker()),
|
||||
("jpeg", _ONE_PIXEL_JPEG),
|
||||
("gif", _ONE_PIXEL_GIF),
|
||||
("webp", _ONE_PIXEL_WEBP),
|
||||
("bmp", _one_pixel_bmp()),
|
||||
],
|
||||
)
|
||||
def test_supported_raster_requires_exact_terminal_boundary(mime, raw):
|
||||
exact = f"data:image/{mime};base64,{base64.b64encode(raw).decode('ascii')}"
|
||||
trailing = (
|
||||
f"data:image/{mime};base64,"
|
||||
f"{base64.b64encode(raw + b'trailing-bytes').decode('ascii')}"
|
||||
)
|
||||
|
||||
assert helpers._is_native_raster_data_uri(exact) is True
|
||||
assert helpers._is_native_raster_data_uri(trailing) is False
|
||||
|
||||
|
||||
def test_webp_animation_frame_requires_real_nested_image_chunk():
|
||||
fake_frame = b"\x00" * 16
|
||||
anmf = b"ANMF" + struct.pack("<I", len(fake_frame)) + fake_frame
|
||||
raw = b"RIFF" + struct.pack("<I", len(anmf) + 4) + b"WEBP" + anmf
|
||||
uri = f"data:image/webp;base64,{base64.b64encode(raw).decode('ascii')}"
|
||||
|
||||
assert helpers._is_native_raster_data_uri(uri) is False
|
||||
@@ -192,6 +192,63 @@ def test_named_custom_provider_config_reasoning_efforts(monkeypatch):
|
||||
monkeypatch.setitem(cfg.cfg, "custom_providers", original)
|
||||
|
||||
|
||||
def test_named_custom_provider_model_reasoning_efforts_take_precedence(monkeypatch):
|
||||
original = cfg.cfg.get("custom_providers")
|
||||
monkeypatch.setitem(
|
||||
cfg.cfg,
|
||||
"custom_providers",
|
||||
[
|
||||
{
|
||||
"name": "llm-proxy",
|
||||
"reasoning_efforts": ["high"],
|
||||
"models": {
|
||||
"Inkling": {
|
||||
"reasoning_efforts": ["none", "low", "medium", "high", "xhigh"]
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
try:
|
||||
assert cfg.resolve_model_reasoning_efforts(
|
||||
"inkling",
|
||||
provider_id="custom:llm-proxy",
|
||||
) == ["none", "low", "medium", "high", "xhigh"]
|
||||
assert cfg.resolve_model_reasoning_efforts(
|
||||
"another-model",
|
||||
provider_id="custom:llm-proxy",
|
||||
) == ["high"]
|
||||
finally:
|
||||
if original is None:
|
||||
cfg.cfg.pop("custom_providers", None)
|
||||
else:
|
||||
monkeypatch.setitem(cfg.cfg, "custom_providers", original)
|
||||
|
||||
|
||||
def test_model_reasoning_efforts_fall_back_to_provider_when_invalid(monkeypatch):
|
||||
original = cfg.cfg.get("providers")
|
||||
monkeypatch.setitem(
|
||||
cfg.cfg,
|
||||
"providers",
|
||||
{
|
||||
"wandb": {
|
||||
"reasoning_efforts": ["none", "high"],
|
||||
"models": {"inkling": {"reasoning_efforts": ["bogus", "typo"]}},
|
||||
}
|
||||
},
|
||||
)
|
||||
try:
|
||||
assert cfg.resolve_model_reasoning_efforts(
|
||||
"inkling",
|
||||
provider_id="wandb",
|
||||
) == ["none", "high"]
|
||||
finally:
|
||||
if original is None:
|
||||
cfg.cfg.pop("providers", None)
|
||||
else:
|
||||
monkeypatch.setitem(cfg.cfg, "providers", original)
|
||||
|
||||
|
||||
def test_acp_guards_win_over_configured_reasoning_efforts(monkeypatch):
|
||||
original = cfg.cfg.get("providers")
|
||||
monkeypatch.setitem(
|
||||
@@ -216,7 +273,16 @@ def test_nested_route_deny_wins_over_configured_reasoning_efforts(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
cfg.cfg,
|
||||
"custom_providers",
|
||||
[{"name": "agg", "reasoning_efforts": ["low", "high"]}],
|
||||
[
|
||||
{
|
||||
"name": "agg",
|
||||
"reasoning_efforts": ["low", "high"],
|
||||
"models": {
|
||||
"vertex/gemini-image-1.0": {"reasoning_efforts": ["high"]},
|
||||
"vertex/gemini-embedding-001": {"reasoning_efforts": ["high"]},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
try:
|
||||
for model in ("vertex/gemini-image-1.0", "vertex/gemini-embedding-001"):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from api.run_journal import (
|
||||
RunJournalWriter,
|
||||
@@ -104,6 +105,120 @@ def test_latest_summary_and_find_run_summary_classify_terminal_state(tmp_path):
|
||||
assert found["terminal_state"] == "interrupted-by-user"
|
||||
|
||||
|
||||
def test_latest_summary_reuses_unchanged_journal_summary_without_reparsing(tmp_path, monkeypatch):
|
||||
append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path)
|
||||
append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path)
|
||||
|
||||
first = latest_run_summary("session_1", "run_1", session_dir=tmp_path)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.run_journal._read_jsonl",
|
||||
lambda _path: (_ for _ in ()).throw(AssertionError("unchanged journal was reparsed")),
|
||||
)
|
||||
repeated = latest_run_summary("session_1", "run_1", session_dir=tmp_path)
|
||||
|
||||
assert repeated == first
|
||||
|
||||
|
||||
def test_summary_cache_invalidates_on_same_size_rewrite_with_restored_mtime(tmp_path, monkeypatch):
|
||||
# A same-inode, same-size rewrite that restores the original mtime_ns (e.g. an
|
||||
# atomic replace, or a tool that preserves mtime) must still invalidate the
|
||||
# cached summary. The signature includes st_ctime_ns — which advances on any
|
||||
# content/metadata change and cannot be forged back — so device/inode/size/
|
||||
# mtime collisions alone can never serve a stale summary. Proven at the
|
||||
# signature level (the enforced TOCTOU precondition for the cache) with a
|
||||
# deterministic stat where ONLY ctime differs.
|
||||
import api.run_journal as run_journal
|
||||
|
||||
append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path)
|
||||
path = run_journal._run_path("session_1", "run_1", session_dir=tmp_path)
|
||||
real = path.stat()
|
||||
|
||||
class _Stat:
|
||||
st_dev = real.st_dev
|
||||
st_ino = real.st_ino
|
||||
st_size = real.st_size
|
||||
st_mtime_ns = real.st_mtime_ns
|
||||
st_ctime_ns = real.st_ctime_ns # overwritten per-call below
|
||||
|
||||
seq = {"ctime": real.st_ctime_ns}
|
||||
|
||||
def fake_stat(self, *a, **k):
|
||||
s = _Stat()
|
||||
s.st_ctime_ns = seq["ctime"]
|
||||
return s
|
||||
|
||||
monkeypatch.setattr(Path, "stat", fake_stat)
|
||||
sig_before = run_journal._summary_cache_signature(path)
|
||||
# Same dev/inode/size/mtime, but a same-size in-place rewrite advanced ctime.
|
||||
seq["ctime"] = real.st_ctime_ns + 1
|
||||
sig_after = run_journal._summary_cache_signature(path)
|
||||
|
||||
assert sig_after is not None and sig_before is not None
|
||||
assert sig_after != sig_before, "signature must change when only ctime advances"
|
||||
assert sig_before[:4] == sig_after[:4], "dev/inode/size/mtime_ns unexpectedly changed"
|
||||
|
||||
|
||||
def test_summary_cache_does_not_store_result_when_journal_changes_during_read(tmp_path, monkeypatch):
|
||||
append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path)
|
||||
append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path)
|
||||
|
||||
import api.run_journal as run_journal
|
||||
|
||||
original_read = run_journal._read_jsonl
|
||||
|
||||
def append_after_read(path):
|
||||
events, malformed = original_read(path)
|
||||
append_run_event(
|
||||
"session_1",
|
||||
"run_1",
|
||||
"cancel",
|
||||
{"message": "Cancelled by user"},
|
||||
session_dir=tmp_path,
|
||||
)
|
||||
return events, malformed
|
||||
|
||||
monkeypatch.setattr(run_journal, "_read_jsonl", append_after_read)
|
||||
|
||||
first = latest_run_summary("session_1", "run_1", session_dir=tmp_path)
|
||||
second = latest_run_summary("session_1", "run_1", session_dir=tmp_path)
|
||||
|
||||
assert first["terminal_state"] == "completed"
|
||||
assert second["terminal_state"] == "interrupted-by-user"
|
||||
|
||||
|
||||
|
||||
def test_summary_cache_rejects_first_append_that_races_missing_journal_read(tmp_path, monkeypatch):
|
||||
import api.run_journal as run_journal
|
||||
|
||||
original_read = run_journal._read_jsonl
|
||||
appended = False
|
||||
|
||||
def append_after_missing_read(path):
|
||||
nonlocal appended
|
||||
events, malformed = original_read(path)
|
||||
if not appended:
|
||||
appended = True
|
||||
append_run_event(
|
||||
"session_1",
|
||||
"run_first_append",
|
||||
"done",
|
||||
{"session": {}},
|
||||
session_dir=tmp_path,
|
||||
)
|
||||
return events, malformed
|
||||
|
||||
monkeypatch.setattr(run_journal, "_read_jsonl", append_after_missing_read)
|
||||
|
||||
raced = latest_run_summary("session_1", "run_first_append", session_dir=tmp_path)
|
||||
refreshed = latest_run_summary("session_1", "run_first_append", session_dir=tmp_path)
|
||||
|
||||
assert raced["terminal_state"] == "unknown"
|
||||
assert refreshed["terminal_state"] == "completed"
|
||||
assert refreshed["last_seq"] == 1
|
||||
assert refreshed["last_event_id"] == "run_first_append:1"
|
||||
|
||||
|
||||
def test_terminal_state_classification_distinguishes_crash_from_user_cancel(tmp_path):
|
||||
append_run_event("session_1", "run_cancelled", "cancel", {"message": "Cancelled by user"}, session_dir=tmp_path)
|
||||
append_run_event("session_1", "run_crashed", "apperror", {"type": "interrupted"}, session_dir=tmp_path)
|
||||
|
||||
@@ -25,6 +25,17 @@ def test_streaming_journals_sse_events_before_queue_delivery():
|
||||
assert "queue_item = (event, data, event_id) if event_id and hasattr(q, \"subscribe_with_snapshot\") else (event, data)" in block
|
||||
|
||||
|
||||
|
||||
def test_streaming_compacts_all_successful_agent_result_writebacks():
|
||||
src = Path("api/streaming.py").read_text(encoding="utf-8")
|
||||
run_src = src[src.index("def _run_agent_streaming("):]
|
||||
|
||||
# Normal completion plus both credential self-heal retry-success paths must
|
||||
# each compact after committing their own result shape. The retries use
|
||||
# different local variable names, so guard the durable invariant itself.
|
||||
assert run_src.count("_compact_session_image_parts_for_persistence(s)") == 3
|
||||
|
||||
|
||||
def test_visible_process_echo_compare_ignores_all_whitespace():
|
||||
token_text = "先把 issue 4249 拉下来\n\n先看正文和评论"
|
||||
interim_text = "先把 issue 4249 拉下来先看正文和评论"
|
||||
|
||||
@@ -325,7 +325,7 @@ def test_approval_respond_does_not_fallback_to_oldest_when_explicit_id_is_stale(
|
||||
helper_body = src[helper_idx:src.index("def _handle_approval_respond", helper_idx)]
|
||||
|
||||
assert "A stale explicit id must not accidentally approve" in helper_body
|
||||
assert "if found_target or not approval_id:" in helper_body
|
||||
assert "if not pending and not approval_id:" in helper_body
|
||||
stale_branch = helper_body[helper_body.index("else:", helper_body.index("for i, entry")):helper_body.index("else:\n pending = queue.pop(0)")]
|
||||
assert "pending = None" in stale_branch
|
||||
assert "queue.pop(0)" not in stale_branch
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -115,6 +115,68 @@ def test_attention_summary_prefers_pending_approvals_over_clarify_questions():
|
||||
_clear_attention_state(sid)
|
||||
|
||||
|
||||
def test_attention_summary_keeps_direct_runs_mirror_after_stream_pointer_clears():
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import api.route_approvals as approvals
|
||||
|
||||
sid = "attention-retained-gateway-session"
|
||||
_clear_attention_state(sid)
|
||||
try:
|
||||
approvals.submit_gateway_pending_mirror(sid, {
|
||||
"_gateway_mirror": True,
|
||||
"approval_id": "approval-retained",
|
||||
"run_id": "run-retained",
|
||||
"command": "rm -rf /tmp/retained",
|
||||
"description": "Retained approval",
|
||||
})
|
||||
handler = MagicMock()
|
||||
handler.wfile = io.BytesIO()
|
||||
|
||||
with patch("api.routes.get_session", return_value=SimpleNamespace(active_stream_id=None)), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
|
||||
assert routes._session_attention_summary(sid) == {
|
||||
"kind": "approval",
|
||||
"count": 1,
|
||||
"severity": "critical",
|
||||
}
|
||||
routes._handle_approval_respond(handler, {
|
||||
"session_id": sid,
|
||||
"choice": "deny",
|
||||
"approval_id": "approval-retained",
|
||||
})
|
||||
|
||||
handler.send_response.assert_called_with(200)
|
||||
respond_approval.assert_called_once_with("run-retained", "", "deny")
|
||||
assert json.loads(handler.wfile.getvalue().decode("utf-8"))["ok"] is True
|
||||
finally:
|
||||
approvals._pending.pop(sid, None)
|
||||
_clear_attention_state(sid)
|
||||
|
||||
|
||||
def test_attention_summary_hides_retired_direct_runs_mirror():
|
||||
import api.route_approvals as approvals
|
||||
|
||||
sid = "attention-retired-gateway-session"
|
||||
_clear_attention_state(sid)
|
||||
try:
|
||||
approvals.submit_gateway_pending_mirror(sid, {
|
||||
"_gateway_mirror": True,
|
||||
"approval_id": "approval-retired",
|
||||
"run_id": "run-retired",
|
||||
"command": "rm -rf /tmp/retired",
|
||||
"description": "Retired approval",
|
||||
})
|
||||
|
||||
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-retired") is True
|
||||
assert routes._session_attention_summary(sid) is None
|
||||
assert approvals.gateway_pending_mirror(sid, run_id="run-retired") is None
|
||||
finally:
|
||||
approvals._pending.pop(sid, None)
|
||||
_clear_attention_state(sid)
|
||||
|
||||
|
||||
def test_attention_summary_reports_clarify_when_no_approval_is_pending():
|
||||
sid = "attention-clarify-session"
|
||||
_clear_attention_state(sid)
|
||||
|
||||
@@ -4,7 +4,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -688,7 +688,6 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path,
|
||||
}
|
||||
|
||||
routes._run_manual_compression_job(sid, {"session_id": sid})
|
||||
|
||||
assert EnvAssertingAgent.seen_env == {
|
||||
"HERMES_HOME": str(profile_home),
|
||||
"HERMES_TEST_PROFILE_ENV": "work-runtime",
|
||||
@@ -697,8 +696,8 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path,
|
||||
"SKILL_MODULE_HOME": profile_home,
|
||||
"SKILL_MODULE_DIR": profile_home / "skills",
|
||||
}
|
||||
assert str(getattr(fake_skill_module, "HERMES_HOME")) == "default-home"
|
||||
assert str(getattr(fake_skill_module, "SKILLS_DIR")) == "default-home/skills"
|
||||
assert str(fake_skill_module.HERMES_HOME) == "default-home"
|
||||
assert str(fake_skill_module.SKILLS_DIR) == "default-home/skills"
|
||||
assert os.environ.get("HERMES_HOME") == "default-home"
|
||||
assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None
|
||||
with routes._MANUAL_COMPRESSION_JOBS_LOCK:
|
||||
|
||||
@@ -47,6 +47,34 @@ def _function_body(src: str, name: str) -> str:
|
||||
raise AssertionError(f"{name} body did not close")
|
||||
|
||||
|
||||
def _function_source(src: str, name: str) -> str:
|
||||
start = src.find(f"function {name}")
|
||||
assert start != -1, f"{name} not found"
|
||||
params = src.find("(", start)
|
||||
assert params != -1, f"{name} parameters not found"
|
||||
depth = 0
|
||||
close = -1
|
||||
for idx in range(params, len(src)):
|
||||
if src[idx] == "(":
|
||||
depth += 1
|
||||
elif src[idx] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
close = idx
|
||||
break
|
||||
assert close != -1, f"{name} parameters did not close"
|
||||
brace = src.find("{", close)
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
if src[idx] == "{":
|
||||
depth += 1
|
||||
elif src[idx] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : idx + 1]
|
||||
raise AssertionError(f"{name} body did not close")
|
||||
|
||||
|
||||
def _registry_snapshot() -> dict:
|
||||
assert NODE, "node is required for assistant_turn_anchors.js registry tests"
|
||||
script = f"""
|
||||
@@ -83,6 +111,7 @@ api.applyAssistantTurnAnchorSourceEvent(registry, {{
|
||||
console.log(JSON.stringify({{
|
||||
version:api.version,
|
||||
registry,
|
||||
scene:api.projectAssistantTurnAnchorActivityScene(registry,{{mode:'compact_worklog'}}),
|
||||
isolated,
|
||||
results:results.map((item)=>({{applied:item.applied, reason:item.reason}})),
|
||||
}}));
|
||||
@@ -723,6 +752,140 @@ def test_registry_routes_activity_artifacts_side_effects_metadata_and_transport(
|
||||
assert anchor["transport_events"][0]["source_event_type"] == "stream_end"
|
||||
|
||||
|
||||
def test_live_state_saved_listener_attaches_side_effect_to_anchor():
|
||||
body = _event_listener_body(_read(MESSAGES_JS), "state_saved")
|
||||
|
||||
assert "_applyToAnchor('state_saved',d,e,null,{render:false});" in body
|
||||
|
||||
|
||||
def test_invisible_anchor_outcome_applies_without_repainting_live_scene():
|
||||
assert NODE, "node is required for assistant-turn ownership tests"
|
||||
apply_source = _function_source(_read(MESSAGES_JS), "_applyToAnchor")
|
||||
script = f"""
|
||||
const activeSid='sid-owned-outcome';
|
||||
const streamId='stream-owned-outcome';
|
||||
const _anchorRegistry={{anchor:{{}}}};
|
||||
const applied=[];
|
||||
const _anchorApi={{
|
||||
applyAssistantTurnAnchorSourceEvent(registry,event,context){{
|
||||
applied.push({{registry,event,context}});
|
||||
return {{applied:true}};
|
||||
}},
|
||||
}};
|
||||
let _anchorShadowWarned=false;
|
||||
let _assistantSegmentSeq=3;
|
||||
let _currentActivityBurstId=4;
|
||||
let renderCount=0;
|
||||
function _renderAnchorLiveScene(){{ renderCount+=1; return true; }}
|
||||
{apply_source}
|
||||
const renderOutcome={{}};
|
||||
const result=_applyToAnchor(
|
||||
'state_saved',
|
||||
{{kind:'memory',name:'session-state'}},
|
||||
{{lastEventId:'run-owned-outcome:7'}},
|
||||
renderOutcome,
|
||||
{{render:false}}
|
||||
);
|
||||
console.log(JSON.stringify({{result,applied,renderCount,renderOutcome}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script], text=True, capture_output=True, check=False
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["result"] == {"applied": True}
|
||||
assert data["renderCount"] == 0
|
||||
assert data["renderOutcome"] == {"rendered": False}
|
||||
assert data["applied"][0]["event"]["source_event_type"] == "state_saved"
|
||||
assert data["applied"][0]["event"]["event_id"] == "run-owned-outcome:7"
|
||||
assert data["applied"][0]["context"] == {
|
||||
"session_id": "sid-owned-outcome",
|
||||
"stream_id": "stream-owned-outcome",
|
||||
}
|
||||
|
||||
|
||||
def test_side_effect_owner_survives_scene_settlement_and_reload():
|
||||
from api import routes
|
||||
|
||||
data = _registry_snapshot()
|
||||
scene = routes._sanitize_anchor_activity_scene(data["scene"])
|
||||
messages = [
|
||||
{"role": "user", "content": "save this"},
|
||||
{"role": "assistant", "content": "final answer"},
|
||||
]
|
||||
message_ref = routes._assistant_anchor_scene_message_ref(messages[1])
|
||||
records = {
|
||||
message_ref: {
|
||||
"version": "anchor_activity_scene_record_v1",
|
||||
"message_index": 1,
|
||||
"message_ref": message_ref,
|
||||
"stream_id": "stream-1",
|
||||
"scene": scene,
|
||||
}
|
||||
}
|
||||
|
||||
hydrated = routes._hydrate_anchor_activity_scenes(messages, records)
|
||||
settled_scene = hydrated[1]["_anchor_activity_scene"]
|
||||
|
||||
assert settled_scene["artifacts"] == scene["artifacts"]
|
||||
assert settled_scene["artifacts"][0]["payload"] == {
|
||||
"kind": "workspace_file",
|
||||
"path": "answer.txt",
|
||||
}
|
||||
assert settled_scene["side_effects"] == scene["side_effects"]
|
||||
assert settled_scene["side_effects"][0]["source_event_type"] == "state_saved"
|
||||
assert settled_scene["side_effects"][0]["payload"] == {
|
||||
"kind": "memory",
|
||||
"name": "session-state",
|
||||
}
|
||||
|
||||
|
||||
def test_side_effect_only_scene_is_persisted_without_creating_a_worklog():
|
||||
assert NODE, "node is required for assistant-turn ownership tests"
|
||||
messages_js = _read(MESSAGES_JS)
|
||||
has_owned_outcomes = _function_body(messages_js, "_anchorSceneHasOwnedOutcomes")
|
||||
attach_scene = _function_body(
|
||||
messages_js, "_attachProjectedAnchorSceneToLastAssistant"
|
||||
)
|
||||
script = f"""
|
||||
const activeSid='sid-owned-outcome';
|
||||
const streamId='stream-owned-outcome';
|
||||
const _anchorRegistry={{}};
|
||||
let persisted=0;
|
||||
function _anchorSceneHasOwnedOutcomes(scene){{{has_owned_outcomes}}}
|
||||
function _anchorSceneHasWorklogWorthyRows(){{ return false; }}
|
||||
function _projectLiveAnchorActivityScene(){{
|
||||
return {{
|
||||
version:'activity_scene_v1',
|
||||
mode:'compact_worklog',
|
||||
activity_rows:[],
|
||||
artifacts:[],
|
||||
side_effects:[{{source_event_type:'state_saved',payload:{{kind:'memory'}}}}],
|
||||
}};
|
||||
}}
|
||||
function _completeSettledAnchorSceneForTurn(messages,index,scene){{ return scene; }}
|
||||
function _persistSettledAnchorScene(){{ persisted+=1; }}
|
||||
function _attachProjectedAnchorSceneToLastAssistant(messages){{{attach_scene}}}
|
||||
const messages=[{{role:'assistant',content:'final answer'}}];
|
||||
const renderedWorklog=_attachProjectedAnchorSceneToLastAssistant(messages);
|
||||
console.log(JSON.stringify({{
|
||||
renderedWorklog,
|
||||
persisted,
|
||||
attached:messages[0]._anchor_activity_scene,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script], text=True, capture_output=True, check=False
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["renderedWorklog"] is False
|
||||
assert data["persisted"] == 1
|
||||
assert data["attached"]["side_effects"][0]["source_event_type"] == "state_saved"
|
||||
|
||||
|
||||
def test_registry_updates_lifecycle_and_settled_final_projection():
|
||||
data = _registry_snapshot()
|
||||
anchor = data["registry"]["anchor"]
|
||||
@@ -979,6 +1142,8 @@ def test_activity_scene_is_renderer_neutral_and_empty_safe():
|
||||
"final_message_ref": None,
|
||||
"terminal_state": None,
|
||||
"activity_rows": [],
|
||||
"artifacts": [],
|
||||
"side_effects": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -786,10 +786,13 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase):
|
||||
|
||||
self.assertEqual(captured.get('hermes_home'), 'profile-home')
|
||||
self.assertEqual(str(captured.get('skill_module_home')), 'profile-home')
|
||||
self.assertEqual(Path(str(captured.get('skill_module_dir'))), Path('profile-home') / 'skills')
|
||||
self.assertEqual(
|
||||
Path(str(captured.get('skill_module_dir'))),
|
||||
Path('profile-home') / 'skills',
|
||||
)
|
||||
self.assertEqual(captured.get('restored_hermes_home'), 'default-home')
|
||||
self.assertEqual(getattr(fake_skill_module, 'HERMES_HOME'), 'default-home')
|
||||
self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills')
|
||||
self.assertEqual(fake_skill_module.HERMES_HOME, 'default-home')
|
||||
self.assertEqual(fake_skill_module.SKILLS_DIR, 'default-home/skills')
|
||||
self.assertEqual(mock_session.title, 'Profile Routed Title')
|
||||
|
||||
def test_background_profile_env_routes_load_config_and_provider_credentials(self):
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -1785,8 +1785,8 @@ class TestUpdateSummaryRouteModelSelection:
|
||||
'SKILL_MODULE_DIR': profile_home / 'skills',
|
||||
}
|
||||
assert captured['aux_create']['model'] == 'profile-compression-model'
|
||||
assert getattr(fake_skill_module, 'HERMES_HOME') == 'default-home'
|
||||
assert getattr(fake_skill_module, 'SKILLS_DIR') == 'default-home/skills'
|
||||
assert fake_skill_module.HERMES_HOME == 'default-home'
|
||||
assert fake_skill_module.SKILLS_DIR == 'default-home/skills'
|
||||
assert os.environ.get('HERMES_HOME') == 'default-home'
|
||||
assert os.environ.get('HERMES_TEST_PROFILE_ENV') == 'default-runtime'
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
@@ -4,6 +4,7 @@ from email.message import Message
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
import api.gateway_chat as gateway_chat
|
||||
@@ -519,6 +520,38 @@ def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp
|
||||
unknown_errors = [item[1] for item in unknown_events if item[0] == "apperror"]
|
||||
assert unknown_errors[-1]["type"] == "error"
|
||||
assert "Gateway provider failed" in unknown_errors[-1]["message"]
|
||||
unknown_payload_error = unknown_errors[-1]["session"]["messages"][-1]
|
||||
assert unknown_payload_error.get("_error") is True
|
||||
assert "_turnDuration" not in unknown_payload_error
|
||||
|
||||
response_error[0] = error_text
|
||||
future_stream_id = "stream-gateway-future-duration-terminal-error-test"
|
||||
s = new_session()
|
||||
s.active_stream_id = future_stream_id
|
||||
s.pending_user_message = "Say hello"
|
||||
s.pending_started_at = time.time() + 30
|
||||
s.pending_attachments = []
|
||||
s.save()
|
||||
future_events = []
|
||||
future_channel = MagicMock()
|
||||
future_channel.put_nowait = lambda item: future_events.append(item)
|
||||
STREAMS[future_stream_id] = future_channel
|
||||
gateway_chat._run_gateway_chat_streaming(
|
||||
s.session_id,
|
||||
"Say hello",
|
||||
"test-model",
|
||||
str(tmp_path),
|
||||
future_stream_id,
|
||||
[],
|
||||
)
|
||||
future_errors = [item[1] for item in future_events if item[0] == "apperror"]
|
||||
assert future_errors[-1]["type"] in {"model_not_found", "auth_mismatch"}
|
||||
saved = models.get_session(s.session_id)
|
||||
assert saved.messages[-1].get("_error") is True
|
||||
assert "_turnDuration" not in saved.messages[-1]
|
||||
future_payload_error = future_errors[-1]["session"]["messages"][-1]
|
||||
assert future_payload_error.get("_error") is True
|
||||
assert "_turnDuration" not in future_payload_error
|
||||
|
||||
response_error[0] = "partial"
|
||||
partial_stream_id = "stream-gateway-partial-terminal-error-test"
|
||||
@@ -553,6 +586,7 @@ def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp
|
||||
assert payload_messages[-2]["_partial"] is True
|
||||
assert payload_messages[-2]["content"] == "partial"
|
||||
assert payload_messages[-1]["_error"] is True
|
||||
assert "_turnDuration" not in payload_messages[-1]
|
||||
|
||||
|
||||
def test_gateway_chat_worker_persists_reasoning_and_tool_state_on_terminal_error(tmp_path, monkeypatch):
|
||||
|
||||
@@ -4,7 +4,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS)
|
||||
PROFILE_CONCEPT_FALLBACK_KEYS = {
|
||||
*PROFILE_CONCEPT_KEYS,
|
||||
"workspace_artifact_source_session",
|
||||
}
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
|
||||
Reference in new issue
Block a user