Merge remote-tracking branch 'origin/master' into HEAD
This commit is contained in:
commit
2a742e13d6
148 files changed
+17501
-950
No files matched your search
@@ -132,6 +132,13 @@
|
||||
# Extra sources for the notes drawer (see docs for the expected format).
|
||||
# HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=
|
||||
|
||||
# Sidebar recency window: how many recent sessions are listed (default: 20).
|
||||
# Also caps how many delegated subagent children can nest at once; raise it
|
||||
# for wide fan-outs. Invalid or non-positive values fall back to 20.
|
||||
# Values above 200 are clamped. Resolved before profile init, so a profile
|
||||
# .env cannot override this server-wide bound.
|
||||
# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20
|
||||
|
||||
# Directory scanned for WebUI plugins (default: ~/.hermes/plugins).
|
||||
# HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/sidebar_terminal_barrier_server.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -23,7 +25,9 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/sidebar_terminal_barrier_server.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -32,7 +36,8 @@ jobs:
|
||||
live-to-final:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
# Keep existing proof rows informational; reconnect regressions fail CI.
|
||||
continue-on-error: ${{ matrix.name != 'reconnect-scene-redraw' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -45,6 +50,9 @@ jobs:
|
||||
scenario: terminal-error
|
||||
- name: historical-transcript-hydration
|
||||
script: tests/browser_historical_transcript_hydration.py
|
||||
- name: reconnect-scene-redraw
|
||||
script: tests/browser_reconnect_scene_redraw.py
|
||||
browsers: chromium
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -69,6 +77,7 @@ jobs:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
|
||||
BROWSERS: ${{ matrix.browsers }}
|
||||
run: python ${{ matrix.script }}
|
||||
|
||||
- name: Run settlement frame proof
|
||||
@@ -79,6 +88,14 @@ jobs:
|
||||
LIFECYCLE_TEST_BITE: settle-worklog-frame-proof
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Recover a missing terminal frame
|
||||
if: matrix.scenario == 'normal'
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts/missing-terminal
|
||||
LIFECYCLE_SCENARIO: normal
|
||||
LIFECYCLE_TEST_BITE: sidebar-idle-missing-terminal
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -67,6 +67,39 @@ Follow that checklist's safety rules:
|
||||
the manual verification performed.
|
||||
- For runtime, streaming, recovery, replay, compression, or sidebar metadata
|
||||
changes, name the state layer being mutated and prove the relevant invariant.
|
||||
- Active-run Steer resolves the stream-bound agent with explicit stream and
|
||||
worker ownership before consulting the reusable session cache. Compression
|
||||
may rotate the agent identity; steering must never evict or close an agent.
|
||||
Keep HTTP response writes outside runtime registry locks. A Gateway-owned
|
||||
active run must resolve to the Gateway outcome before any local cache fallback,
|
||||
even when no in-process worker is registered for that stream. Stop publishes
|
||||
cancellation and detaches stream/agent entries under the same stream lock
|
||||
(STREAMS_LOCK -> ACTIVE_RUNS_LOCK); interrupt and session persistence remain
|
||||
outside it. The retained cache-only path (no registered worker) revalidates
|
||||
stream membership, owner, and active-run session/backend/phase and enqueues
|
||||
agent.steer() under that same lock edge, so a Stop that claims cancellation
|
||||
never strands guidance an earlier Steer response reported as accepted. Test
|
||||
both Stop/Steer orderings — registered and cache-only — with deterministic
|
||||
barriers. Initial active-run publication and its cancel flag must share that
|
||||
lock edge with Stop; do not recreate the flag after journal setup. Worker
|
||||
registration must also check its retained cancel event and live stream
|
||||
membership: Stop can remove CANCEL_FLAGS during initialization.
|
||||
Do not re-register a cancelled worker; finalize outside the stream lock.
|
||||
Local Steer accepts only explicit starting/running phases. Close admission
|
||||
by publishing finalizing under STREAMS_LOCK before the last pending-steer
|
||||
drain; earlier accepted guidance is drained, later guidance is rejected with
|
||||
`not_running` while the owned stream is still live (not `stream_dead`). Use
|
||||
one idempotent terminal settlement before done/error/end and final cleanup,
|
||||
covering returned errors, exceptions and self-heal, not only the success path.
|
||||
Merge Agent-returned `pending_steer` with the registered worker's final slot
|
||||
drain and emit leftovers before terminal events, outside registry locks.
|
||||
Test both drain/Steer orderings, including compression-rotated identities.
|
||||
- Inactive-session recovery is separate from live Steer. Resolve durable
|
||||
compression lineage in the session's profile database, read-only, even when
|
||||
the WebUI sidecar has no snapshot flag. Never reopen a sealed parent. Reject
|
||||
stale chat POSTs before workspace/model/pending-state mutation; the browser
|
||||
loads the continuation and preserves the draft without automatic replay.
|
||||
Explicit closures and unknown terminal reasons do not authorize a redirect.
|
||||
- For Docker build changes in `docker_init.bash`, mirror directory exclusions
|
||||
in both the `rsync` and `cp -a` paths — `/opt/hermes` may contain subdirectories
|
||||
with restricted permissions (e.g. `.playwright/`).
|
||||
|
||||
@@ -237,6 +237,29 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy):
|
||||
title_from(): takes messages list, finds first user message, returns first 64 chars.
|
||||
Called after run_conversation() completes to set the session title retroactively.
|
||||
|
||||
#### Session transcript reconciliation with `state.db`
|
||||
|
||||
`reconciled_state_db_messages_for_session()` uses
|
||||
`merge_session_messages_append_only()` to combine a WebUI sidecar or context
|
||||
projection with active Agent `state.db` rows. Its explicit
|
||||
`incoming_provenance="state_db"` fence permits a state-only row whose timestamp
|
||||
predates the sidecar tail to use a safe chronological slot. Paginated
|
||||
`msg_limit` consumers rely on this merged order directly rather than applying a
|
||||
later timestamp sort.
|
||||
|
||||
The same merge helper also stitches ordered child sidecars onto archived
|
||||
compression parents. Those calls leave incoming provenance unverified, so their
|
||||
stable message sequence remains append-only even when parent rows carry later,
|
||||
restamped timestamps. Timestamp alone is never authority to move a continuation
|
||||
inside its parent transcript.
|
||||
|
||||
If an older row could only be placed before the first surviving sidecar/context
|
||||
row, the insertion helper declines it to avoid resurrecting compacted history.
|
||||
Reconciliation then appends that row instead of dropping it; rows without a
|
||||
usable timestamp and rows at or after the sidecar tail also append normally.
|
||||
The fallback therefore preserves an accepted state-only row when exact ordering
|
||||
is ambiguous, while safely placeable recovery rows remain chronological.
|
||||
|
||||
#### Imported `state.db` sidebar projection
|
||||
|
||||
`api.models.get_cli_sessions()` projects conversations from the active Hermes
|
||||
|
||||
+249
@@ -3,8 +3,256 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Full-session resolve concurrency is configurable.** `HERMES_WEBUI_MAX_SESSION_RESOLVE` sets how
|
||||
many full-transcript session resolves may run at once (default 2, a positive integer up to 64;
|
||||
zero, negative, non-numeric or out-of-range values fall back to 2). It is process-wide, so a
|
||||
profile's `.env` cannot override it. (#7421, #7656 by @happy5318)
|
||||
- **The sidebar's recent-session window is configurable.** `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` sets
|
||||
how many recent sessions the sidebar lists (default 20). It also bounds how many delegated subagent
|
||||
children can nest at once, so raise it for wide fan-outs. Invalid or non-positive values fall back to
|
||||
20, values above 200 are clamped, and it is resolved before profile init so a profile `.env` cannot
|
||||
override it. (#7631 by @carlotestor)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Opening a session while its task is still running is much faster.** Rebuilding the live
|
||||
snapshot from the run journal parsed the journal twice, walked every metering row and grew the
|
||||
reasoning text with repeated string concatenation, which is quadratic on long runs. The journal
|
||||
is now parsed once, metering rows are skipped (their timestamp watermark is kept) and reasoning
|
||||
deltas are joined once per segment. On a 15 MB / 22.7k-event journal the author measured the
|
||||
rebuild going from 4.34 s to 0.49 s with a byte-identical snapshot. The interim-echo check now matches a
|
||||
compact-equivalent suffix without a fixed window, so an echo stretched by interior whitespace is
|
||||
no longer shown twice. (#7310, #7569 by @happy5318)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A session deleted during a restart no longer produces a spurious recovery warning.** When
|
||||
WebUI startup recovery re-attached background processes, a session that had vanished between
|
||||
enumeration and rebind raised a `KeyError` that was logged as a warning. It now follows the
|
||||
existing skip path, confined to the session lookup, so the vanished owner is skipped, live owners
|
||||
still rebind, and registry errors still warn. (#7753, #7774 by @happy5318)
|
||||
|
||||
- **Waiting on the Agent's session lease is shown as a warning instead of looking stuck.** When
|
||||
another Hermes process (gateway, CLI or cron) holds the session's turn lease, the Agent's
|
||||
"another Hermes process is using this session" notices now reach the chat as a warning status
|
||||
instead of being dropped, and the status clears when the run ends. Classification keys on the Agent
|
||||
status kind (`lifecycle` / `warn`), so user-authored text can never be promoted to a warning.
|
||||
(#7760 by @ruizanthony)
|
||||
- **A stale in-flight projection can no longer reach a gateway watcher after its last subscriber
|
||||
leaves.** Final unsubscribe and queue eviction now invalidate the cache and fence projections that
|
||||
were already in flight, without holding the lock across database reads or SSE writes, so a client
|
||||
that re-subscribes gets a fresh snapshot instead of a stale one. (#7761 by @ruizanthony)
|
||||
- **A burst of "session busy" refusals no longer drops a background-task completion.** When
|
||||
`start_session_turn()` refused a completion wake-up with a transient 409 (Agent runtime stale,
|
||||
process wake-ups paused, or the session busy with another turn), the bridge released the durable
|
||||
claim as a plain failure, so a few refusals in a row could terminally drop a completion whose
|
||||
session was alive and waiting. Transient refusals now return the claim as retryable, while hard
|
||||
failures still use up the attempt budget. (#7758 by @ruizanthony)
|
||||
- **A first visit now uses the browser's language.** The server stores "no preference" as `null`
|
||||
instead of defaulting to `"en"`, so a first-time visitor gets `navigator.language` while an
|
||||
explicitly saved language (including English) still wins, and legacy `settings.json` files that
|
||||
already hold `"en"` keep English. Reading the browser language is guarded, so an environment where
|
||||
`navigator` throws falls back cleanly. (#7622, #7730 by @happy5318)
|
||||
- **The live model list for a custom provider respects its `models:` allowlist, without treating
|
||||
per-model metadata as one.** `/api/models/live` filters a custom provider's live catalog to its
|
||||
configured `models:` when that is a list, or when it is a mapping with `discover_models: false`.
|
||||
A mapping of per-model settings (the shape `hermes setup` writes) keeps the full live catalog, as
|
||||
the Agent does. (#7165 by @happy5318)
|
||||
- **`MEDIA:` links wrapped in inline code no longer 404.** Every `MEDIA:` capture site (renderer,
|
||||
streaming parser, TTS stripper, session-media authorization and snapshot capture, seven in all)
|
||||
swallowed the closing backtick of `` `MEDIA:/path` `` into the path, so the file lookup and the
|
||||
session allowlist both missed. Backtick-wrapped refs are now normalized first, while bare paths that
|
||||
genuinely contain a backtick keep their full name. (#7359, #7708 by @happy5318)
|
||||
- **A turn's Worklog no longer vanishes when the sidebar reports idle before the final frame.**
|
||||
`/api/sessions` could say a session was idle before the chat stream's terminal frame reached the
|
||||
page, and three sidebar paths (idle reconciliation, the INFLIGHT purge and optimistic-row
|
||||
retirement) then erased the pane's Worklog and stream id, so the late `done` was rejected as stale
|
||||
and the settled scene was never saved. The cleanup now waits briefly for the pane's own open
|
||||
stream, then checks `/api/chat/stream/status` once, and falls back to the existing
|
||||
interrupted-stream recovery if that check fails. (#7749 by @franksong2702)
|
||||
- **A session's run journal can no longer be written out of order.** The journal writer reserved a
|
||||
sequence number under the per-path lock, released it, and then appended, so two concurrent writers
|
||||
could land sequence N+1 on disk before N and the replay reader would stop at the gap
|
||||
(`replay_noncontiguous`). Sequence allocation and the physical append now happen under the same
|
||||
existing lock. (#7751 by @franksong2702)
|
||||
- **A stopped chat can no longer publish or reuse its agent after Stop.** A worker that was
|
||||
cancelled could still publish its cached Agent or invoke it after Stop landed, and its late
|
||||
cleanup could close or evict the same Agent object a successor turn had just picked up. The initial
|
||||
path and both credential self-heal paths now take one Stop admission, the cancellation event, live
|
||||
stream and exact Agent are rechecked immediately before invocation (no registry lock is held across
|
||||
provider or tool execution), and cache/lifecycle handles are retired only while the surviving owner
|
||||
still matches. (#7748 by @franksong2702)
|
||||
- **Reconnecting to a running session no longer redraws every tool card over and over.** After a
|
||||
reconnect restored the live activity scene from the run journal, the client also replayed its older
|
||||
cached in-flight tool list on top, so a turn with N tool cards redrew them N times. When the
|
||||
journal-backed scene restores successfully the replay is now skipped (newer rows still arrive
|
||||
through the reattached stream); the replay stays for legacy restores and for a failed or
|
||||
unavailable scene. (#7436, @atchisonbrent)
|
||||
|
||||
- **A settled assistant answer is no longer shown twice (#2051).** Two client-side paths could put a
|
||||
second copy of a finished answer on screen after a turn settled: a stale live-turn node that was
|
||||
still treated as live, and a settled rebuild branch that appended a turn instead of replacing it.
|
||||
The same fix stops an interrupted turn's notice from appearing twice, and stops the composer's
|
||||
model picker from listing one configured custom-provider model twice. The stored transcript was
|
||||
always correct; only the rendering duplicated it. (#7374, @Thireus)
|
||||
|
||||
- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the
|
||||
background, its stream poll kept asking for a session every few seconds after it was deleted or
|
||||
archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network
|
||||
errors keep retrying, a late `404` for one session cannot stop another session's poll, and making
|
||||
the tab visible again reopens the live stream. (#7299, #7716 by @happy5318)
|
||||
- **The OpenRouter setup no longer offers a model OpenRouter doesn't serve.** Onboarding offered
|
||||
`z-ai/glm-4.5-flash`, which is not in OpenRouter's catalog, so picking it failed on the first
|
||||
message. That slot now offers `z-ai/glm-4.5-air`, Z.AI's nearest light model. The direct Z.AI
|
||||
setup still offers GLM-4.5 Flash. (#7520, #7734 by @MuhammadUsamaMX)
|
||||
- **Opening the sidebar can no longer stall the agent's writes to `state.db`.** Several WebUI
|
||||
paths that only read the agent's `state.db` could quietly become writers. The read-only opener
|
||||
fell back to a writable connection when `mode=ro` failed. The session listing self-healed a
|
||||
missing `idx_messages_session` with `CREATE INDEX` through its own writable connection, which
|
||||
holds the SQLite writer lock for minutes on a large `messages` table. The cron sidebar, insights
|
||||
and deep-health checks opened the database with a bare `sqlite3.connect()`. Every one of these
|
||||
readers now opens strictly read-only (`file:...?mode=ro`) and never creates an index. A missing
|
||||
index falls back to the existing pre-aggregated listing. The gateway watcher also no longer polls
|
||||
the database when no client is subscribed. Operators with an older agent can create the read
|
||||
indexes in a drained maintenance window with `scripts/ensure_state_db_read_indexes.py`; see
|
||||
`docs/troubleshooting.md`. (#7445 by @ruizanthony)
|
||||
|
||||
- **A failed chat launch no longer leaves the session stuck "running".** If the worker thread
|
||||
couldn't start, its stream ownership and the session's pending-stream fields were never cleared,
|
||||
so the session could look permanently active and the registries grew. Launch failure now clears
|
||||
exactly that stream's records, after releasing the chat-start lock, and never evicts a successor
|
||||
stream. (#6869, #6937 by @jbdrak)
|
||||
- **A compressed conversation no longer shows up twice in the sidebar.** When context
|
||||
compression started the continuation a few milliseconds before the parent was marked ended, the
|
||||
continuation wasn't recognised. The sidebar then showed a duplicate same-title row plus a
|
||||
spurious child-session entry, and opening the conversation didn't stitch the transcript. Both the
|
||||
sidebar and the transcript stitcher now accept a bounded (2 s) early start when every other
|
||||
lineage signal agrees: same source, direct parent link, a compression (or `cli_close`) end reason and not a fork.
|
||||
(#6931, #7021 by @webtecnica)
|
||||
- **The profile switcher no longer 500s in a two-container Docker setup.** With the agent
|
||||
source not mounted (`HERMES_WEBUI_CHAT_BACKEND=gateway`), `GET /api/profiles` fell through to
|
||||
a skills-stats fallback that imported `agent.skill_utils` unguarded, so the missing module
|
||||
surfaced as an error on every profile-list load. The import is now guarded and the skill
|
||||
stats report as unknown in that case, so the picker stays usable (the skills line is simply
|
||||
omitted). Thanks @webtecnica. (#7305, #7312)
|
||||
|
||||
- **A title that mixes Chinese, Japanese or Korean with English terms is no longer rejected.**
|
||||
The cross-script guard that stops a generated title from drifting into the wrong language
|
||||
treated the borrowed Latin words in a CJK title as drift, so a valid title like
|
||||
`WeChat Pay 回调失败排查` or `Python 代码修复` was thrown away and the session kept its
|
||||
fallback title. Latin terms are now accepted when the title also contains CJK text, while
|
||||
an all-Latin title for a CJK conversation is still rejected. Thanks @MuhammadUsamaMX.
|
||||
(#7693, #7727)
|
||||
|
||||
- **A late-arriving prompt no longer renders below the reply it asked for.** When a message
|
||||
reached the transcript from `state.db` after the sidecar had already been merged⟪HERMES-CONTEXT-COMPRESSION: 809 of 1,009 chars omitted here by Hermes's context compressor. This is NOT part of the original tool call and must never be reproduced in new output — always write full, untruncated content.⟫- **A rejected request no longer poisons the next one on the same connection.** `server.py`
|
||||
is a raw HTTP/1.1 handler where `rfile` is the socket itself, so answering a request
|
||||
before reading its body left those bytes queued. The next request on a keep-alive
|
||||
connection was then parsed starting mid-body, and the client got
|
||||
`400 Bad request syntax ('{"a": "b"}GET /api/health HTTP/1.1')` — an error naming a
|
||||
request it never sent, which is expensive to diagnose from the client side. Every
|
||||
reject path now arms `Connection: close` when a body is still pending, and framing is
|
||||
validated strictly (RFC 9110 `1*DIGIT`, duplicate and comma-combined `Content-Length`
|
||||
reconciled, every `Transfer-Encoding` refused since nothing here decodes one). A
|
||||
genuinely bodyless rejection keeps its keep-alive, so healthy pooled connections are
|
||||
not dropped. Thanks @rodrigogs. (#7550, #6658)
|
||||
|
||||
- **Opening a session that belongs to another profile now offers to switch to it instead of
|
||||
looking deleted.** Several cross-profile guards answered `404 Session not found`, which the
|
||||
front-end treats as a missing session and self-heals by clearing the URL and local storage —
|
||||
so a valid deep link into another profile's session destroyed its own way back. Those guards
|
||||
now return the same `409 session_profile_mismatch` the detail-load endpoint has used since
|
||||
#5419, while a genuinely missing session still 404s and still self-heals. The clarify card,
|
||||
compression-recovery card and manual-compression flow were each treating *every* `409` as
|
||||
their own "stale" signal and are now scoped so a cross-profile refusal no longer hides a live
|
||||
prompt or reports a false compression failure. Thanks @happy5318. (#7710, #7714)
|
||||
|
||||
- **An OIDC-only or passkey-only deployment no longer shows a dead password prompt.** The
|
||||
`/login` page rendered the password input, submit button and passkey control
|
||||
unconditionally, so an instance with native OIDC configured and no
|
||||
`HERMES_WEBUI_PASSWORD` displayed a password form that silently 401'd every submit. The
|
||||
controls are now gated on the auth methods actually configured, with the passkey button
|
||||
kept for passwordless-passkey instances where it is the only login affordance. Thanks
|
||||
@happy5318. (#7056, #7715)
|
||||
|
||||
- **A model whose native id contains a slash no longer poisons its configured-model badge.**
|
||||
The badge builder synthesised a `{provider}/{model}` alias alongside the bare id, so a
|
||||
model already carrying a slash (for example `commandcode` + `deepseek/deepseek-v4-flash`)
|
||||
produced `commandcode/deepseek/deepseek-v4-flash` — a non-functional id that leaked into
|
||||
the badge map, persisted into session state and returned `HTTP 400` from the agent. Only
|
||||
the bare id and the `@provider:model` form are emitted now. Thanks @happy5318. (#7290, #7709)
|
||||
|
||||
- **A conversation whose history carries an explicit `null` tool-call list no longer breaks
|
||||
the tool-call summary.** `_extract_tool_calls_from_messages` used `.get('tool_calls', [])`,
|
||||
which returns the default only when the key is *absent* — when a stored message carried the
|
||||
key with a `null` value, the code tried to iterate `None` and raised `TypeError`. Reading
|
||||
such a session now skips the empty entry and still summarises the tool calls that follow it.
|
||||
Thanks @KayZz69. (#7265)
|
||||
|
||||
- **The opencode-go provider lists its real models again instead of a frozen snapshot.** Model
|
||||
discovery had fallen back to a hard-coded catalog, so models added or removed upstream never
|
||||
appeared. The provider now queries live, with the lookup scoped to opencode-go alone: a slow
|
||||
or unreachable endpoint falls back to the static catalog rather than blocking the picker for
|
||||
anyone else, older Agent builds that lack the discovery API keep the previous behaviour, and a
|
||||
configured model allowlist still wins over whatever discovery returns. Thanks
|
||||
@shameez-struggles-to-commit. (#7220)
|
||||
|
||||
- **Denying a gateway approval now retires the request instead of leaving the run waiting.**
|
||||
When an approval routed through the gateway was denied, the deny reached the agent but the
|
||||
local producer was never retired, so the run could sit waiting on a decision that had already
|
||||
been made. Denial now settles the producer atomically, scoped to the exact
|
||||
`(session_id, run_id, approval_id)` that was answered — sibling approvals from the same run
|
||||
and approvals from other runs are left untouched — and repeated or late responses are bounded
|
||||
no-ops rather than resurrecting a settled request. Thanks @snoyberg. (#7570)
|
||||
|
||||
- **The approval card's "Skip all this session" button no longer shows two lightning bolts.**
|
||||
The button rendered its ⚡ twice — once from the icon span in the markup and again from the
|
||||
translated label, which carried its own leading glyph in 14 of 15 locales. The icon now comes
|
||||
only from the markup, matching every sibling button on the card (Allow once / Allow session /
|
||||
Always allow / Deny all pair an icon element with a glyph-free label), and translators no
|
||||
longer carry the symbol in their strings. (#7701)
|
||||
|
||||
- **The gateway watcher no longer polls `state.db` around the clock with nobody listening.**
|
||||
Its poll loop re-fingerprinted the gateway state database every few seconds whether or not
|
||||
any SSE client was attached, and slept in 0.1s increments — roughly 10 wakeups a second, all
|
||||
day, on an idle server. The loop now parks on an event when there are no subscribers and is
|
||||
woken by the first one, so an idle instance does no polling work at all; while subscribed it
|
||||
waits on a single timer that still returns immediately on shutdown. Connecting a client
|
||||
remains prompt — the first subscriber unparks the loop rather than waiting out the poll
|
||||
interval. Thanks @DevNexsler. (#7694)
|
||||
|
||||
- **A long-running turn no longer replays a stale token count after a reload.** The run-journal
|
||||
recorded live metering frames, so reattaching to a stream — or reloading a tab mid-turn —
|
||||
could replay a snapshot from earlier in the same run and briefly show token/TPS figures that
|
||||
had already been superseded. Metering is now live-only and never journaled, and replayed
|
||||
frames no longer carry an event id that could advance the client's resume cursor past real
|
||||
content. Reattach and reload now show the current numbers for the turn in progress. Thanks
|
||||
@laitekin. (#7291)
|
||||
|
||||
- **Steering a conversation works again after the context is compressed.** When compression
|
||||
rotated `agent.session_id`, a steer could no longer find the active worker: it was either
|
||||
silently dropped or accepted and never delivered. Steers now resolve the owning worker
|
||||
directly rather than relying on a cache lookup that compression invalidates, and every
|
||||
terminal exit — normal completion, returned error, raised exception and self-heal — passes
|
||||
through one idempotent settle boundary, so guidance can no longer be stranded between the
|
||||
final drain and teardown. Two related defects are fixed alongside it: **Stop now actually
|
||||
stops** a run that had already passed preflight (previously the worker could consult a
|
||||
removed registry entry, miss the cancellation, and continue to completion), and a live
|
||||
`finalizing` run is no longer reported as `stream_dead`, which had caused the client to tear
|
||||
down its state while the stream was still alive. Compressed sessions from the CLI, TUI,
|
||||
Desktop and ACP now resume correctly, and installations running an older Agent keep their
|
||||
existing sidecar recovery instead of being left unresumable. Thanks @ruizanthony. (#7546)
|
||||
|
||||
- **Slash-command autocomplete stops offering commands the WebUI cannot run.** The composer's
|
||||
`/` menu announced all 51 registered commands, but many are CLI-only — picking one produced
|
||||
a command that went nowhere. The menu now announces only the WebUI-dispatchable subset (16),
|
||||
so the difference shows up where it matters: typing a prefix like `/a` or `/re` no longer
|
||||
fills the list with dead options. Filtering is confined to the suggestion list — manually
|
||||
typing a CLI-only command behaves exactly as before, and plugin commands plus the native
|
||||
`/moa`, `/sessions`, `/resume` and `/pet` remain available. Thanks @webtecnica.
|
||||
|
||||
- **Concurrent background completions stop burning the async-delegation delivery budget.**
|
||||
When several delegated runs finished at once and idle-woke the same session together, each
|
||||
wakeup consumed a delivery attempt before discovering the others, so the budget could be
|
||||
@@ -46,6 +294,7 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **Two flaky tests are stable again.** Three `get_available_models()` cache-metadata tests raced the 4-second live-rebuild budget on a loaded CI host and could time out onto the stale-cache path. They now pin the budget to `0`, which is the documented setting for the legacy unbounded synchronous rebuild, so they still exercise the real rebuild (#7735). A source-text oracle that string-matched `delete_cli_session`'s source to prove it opens a writable connection is removed. The behavioral test in `test_issue1494_state_db_fd_leak.py` still guards that contract: it fails with `attempt to write a readonly database` if the delete path is ever switched to a read-only connection (#7726). Test-only; no runtime change. Thanks @webtecnica. (#7744, #7742)
|
||||
- **The chat composer grows natively instead of being resized by JavaScript on every keystroke.** `autoResize()` measured `scrollHeight` and wrote `style.height` on each input event — a forced synchronous reflow on the most-typed-in surface in the app. Browsers that support CSS `field-sizing: content` (Chromium today; also Firefox 152 and Safari 26.2) now own the geometry directly, gated on `CSS.supports()`, and the existing JavaScript path is untouched for every other engine. Measured behaviour is identical across both paths: 44px resting height, no jump when the first character is typed or the last deleted, growth to the 200px ceiling, then internal scrolling. Because `field-sizing` deliberately includes placeholder text in content sizing, `:placeholder-shown` pins fixed sizing while the composer is empty so a long placeholder can't inflate it. Thanks @starship-s. (#6760, #5514)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -373,6 +373,7 @@ Full list of environment variables:
|
||||
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
|
||||
| `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_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default. Values above 200 are clamped. Resolved before profile init, so a profile `.env` cannot override it |
|
||||
| `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` | `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 |
|
||||
|
||||
|
||||
+29
-6
@@ -154,12 +154,35 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \
|
||||
python tests/browser_historical_transcript_hydration.py
|
||||
```
|
||||
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs the current
|
||||
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) 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.
|
||||
The dedicated `Conversation lifecycle (informational)` workflow keeps the existing
|
||||
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`)
|
||||
non-blocking while the public matrix expands. The Chromium
|
||||
`reconnect-scene-redraw` row does **not** allow failures: it exercises the real
|
||||
`loadSession` reconnect path and fails the workflow on a regression. Required
|
||||
merge checks remain a maintainer-controlled repository setting.
|
||||
The maintainer's private QA harness remains broader; later public slices will
|
||||
add cancellation, compression, and recovery coverage.
|
||||
|
||||
### Active-session reconnect redraw gate
|
||||
|
||||
`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser
|
||||
gate for reconnecting to a running session with a large Anchor activity scene.
|
||||
It uses an isolated temporary server/home and fixture SSE events—no agent,
|
||||
provider credentials, or production state. By default it checks Chromium and
|
||||
WebKit, desktop and 390px viewports, and all three activity display modes:
|
||||
|
||||
```bash
|
||||
pip install playwright
|
||||
python -m playwright install chromium webkit
|
||||
python tests/browser_reconnect_scene_redraw.py
|
||||
```
|
||||
|
||||
`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1`
|
||||
records an unoptimized baseline without enforcing the redraw ceiling;
|
||||
`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture
|
||||
screenshots. This gate tests page reconstruction, journal-cursor resume, and
|
||||
subsequent fixture SSE updates. It does not test a real provider/network or PWA
|
||||
service-worker cache behavior.
|
||||
|
||||
### Streaming reader intent
|
||||
|
||||
|
||||
+141
-59
@@ -1,8 +1,12 @@
|
||||
"""Shared helpers for reading Hermes Agent sessions from state.db."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote, quote_from_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,36 +22,71 @@ logger = logging.getLogger(__name__)
|
||||
_SOURCE_COLUMN_WARNED_DB_PATHS: set[str] = set()
|
||||
|
||||
|
||||
def state_db_file_uri(db_path, platform: str | None = None) -> str:
|
||||
"""Build the ``file:`` URI (no query string) for an absolute ``state.db`` path.
|
||||
|
||||
``Path.as_uri()`` is not usable here: for a UNC share it emits
|
||||
``file://server/share/state.db`` and SQLite rejects any non-local URI
|
||||
authority unless compiled with ``SQLITE_ALLOW_URI_AUTHORITY`` (the
|
||||
CPython 3.11-3.13 Windows builds are not). SQLite instead accepts the
|
||||
*empty*-authority spelling ``file:////server/share/state.db``, whose path
|
||||
``//server/share/...`` the Windows VFS opens as the UNC name. Drive-letter
|
||||
paths keep the documented ``file:///C:/...`` form and POSIX paths are
|
||||
unchanged. Only ``/`` survives unescaped, so ``?`` and ``#`` in path
|
||||
components cannot leak into the query string.
|
||||
|
||||
``platform`` defaults to the running interpreter; tests pass it explicitly
|
||||
so the Windows shapes are checked from any host.
|
||||
"""
|
||||
platform = platform or sys.platform
|
||||
if platform == "win32":
|
||||
win = PureWindowsPath(str(db_path))
|
||||
drive = win.drive
|
||||
if drive.startswith("\\\\?\\"):
|
||||
# Extended-length prefix: "\\?\C:" or "\\?\UNC\server\share".
|
||||
drive = drive[4:]
|
||||
if drive.upper().startswith("UNC\\"):
|
||||
drive = "\\\\" + drive[4:]
|
||||
parts = win.parts[1:] # drop the anchor, keep the path components
|
||||
if drive.startswith("\\\\"):
|
||||
host_share = drive[2:].replace("\\", "/")
|
||||
posix_path = "//" + "/".join((host_share, *parts))
|
||||
else:
|
||||
posix_path = "/" + "/".join((drive, *parts))
|
||||
# ``:`` stays literal so the drive letter keeps SQLite's documented
|
||||
# ``file:///C:/...`` shape (``Path.as_uri()`` leaves it unescaped too).
|
||||
return "file://" + quote(posix_path, safe="/:")
|
||||
posix_path = PurePosixPath(str(db_path)).as_posix()
|
||||
# Quote the filesystem BYTES, not the str: a POSIX path component that is
|
||||
# not valid UTF-8 is carried in the str as ``surrogateescape`` code points,
|
||||
# which ``quote(str)`` rejects with UnicodeEncodeError (the caller then
|
||||
# treats the db as unreadable and every agent-backed session vanishes).
|
||||
# ``Path.as_uri()`` — what master used — percent-encodes os.fsencode()
|
||||
# bytes; this keeps that behavior.
|
||||
return "file://" + quote_from_bytes(os.fsencode(posix_path), safe="/")
|
||||
|
||||
|
||||
def state_db_readonly_uri(db_path, platform: str | None = None) -> str:
|
||||
"""Strict read-only (``mode=ro``) form of :func:`state_db_file_uri`."""
|
||||
return state_db_file_uri(db_path, platform=platform) + "?mode=ro"
|
||||
|
||||
|
||||
def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> sqlite3.Connection:
|
||||
"""Open the live agent ``state.db`` read-only for a pure-read projection.
|
||||
|
||||
Same rationale as the session-listing path (#5455): a write-capable handle
|
||||
on the multi-GB, WAL ``state.db`` while the agent streams into it adds
|
||||
needless checkpoint/lock surface. The read-only ``file:...?mode=ro`` URI
|
||||
avoids that. Falls back to a writable connection (and warns) if the
|
||||
read-only open fails, so callers never lose data on exotic filesystems.
|
||||
avoids that. Read failures propagate; a reader never upgrades to a writer.
|
||||
|
||||
The caller must ensure ``db_path`` exists — this raises ``FileNotFoundError``
|
||||
for a missing path rather than letting the writable fallback below create an
|
||||
empty, writable ``state.db`` there (a ghost DB in the agent's HOME). The
|
||||
fallback is only for an *existing* DB whose read-only open fails on an exotic
|
||||
filesystem, so a real read never loses data.
|
||||
for a missing path rather than creating a ghost database.
|
||||
|
||||
Callers own the returned connection (wrap it in ``contextlib.closing``).
|
||||
"""
|
||||
log = log or logger
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"agent state.db not found: {db_path}")
|
||||
read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro"
|
||||
try:
|
||||
return sqlite3.connect(read_only_uri, uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
log.warning(
|
||||
"agent state.db read-only open failed for %s; falling back to writable connection: %s",
|
||||
db_path,
|
||||
exc,
|
||||
)
|
||||
return sqlite3.connect(str(db_path))
|
||||
return sqlite3.connect(state_db_readonly_uri(db_path.resolve()), uri=True)
|
||||
|
||||
|
||||
MESSAGING_SOURCES = {
|
||||
@@ -65,6 +104,15 @@ MESSAGING_SOURCES = {
|
||||
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
|
||||
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2
|
||||
|
||||
# Sub-second scheduling/write-order races during a compression/cli_close
|
||||
# handoff can persist the continuation row with ``started_at`` a few
|
||||
# milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance
|
||||
# below lets those rows still classify as the next segment; the fork /
|
||||
# model_config branch-marker / cross-source / end_reason guards in
|
||||
# ``_is_continuation_session`` already rule out unrelated rows, so a small
|
||||
# window cannot collapse genuinely concurrently started children.
|
||||
CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0
|
||||
|
||||
SOURCE_LABELS = {
|
||||
'acp': 'ACP',
|
||||
'api_server': 'API',
|
||||
@@ -318,6 +366,30 @@ def is_cli_session_row_visible(row: dict) -> bool:
|
||||
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
|
||||
|
||||
|
||||
def _branch_markers(row: dict | None) -> tuple[str | None, str | None]:
|
||||
"""Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``.
|
||||
|
||||
Hermes Agent stamps explicit branches and delegate/subagent runs in the
|
||||
``model_config`` JSON column (``_branched_from`` / ``_delegate_from``);
|
||||
``session_source='fork'`` alone only covers WebUI-created forks. Missing,
|
||||
empty, or unparsable ``model_config`` degrades to ``(None, None)``.
|
||||
"""
|
||||
if not row:
|
||||
return None, None
|
||||
raw = row.get('model_config')
|
||||
if isinstance(raw, dict):
|
||||
config = raw
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
parsed = None
|
||||
config = parsed if isinstance(parsed, dict) else {}
|
||||
else:
|
||||
config = {}
|
||||
return config.get('_branched_from'), config.get('_delegate_from')
|
||||
|
||||
|
||||
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
"""Return True when ``child`` is the next segment of the same conversation.
|
||||
|
||||
@@ -325,17 +397,36 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
by ``hermes -c`` also records a new child session; for sidebar projection it
|
||||
should continue the same visible conversation rather than becoming a
|
||||
separate child-session row. Plain parent/child links that started before the
|
||||
parent's ended boundary remain child sessions.
|
||||
parent's ended boundary remain child sessions. A small scheduling/write-order
|
||||
overlap between the handoff boundary timestamps is tolerated (#6931).
|
||||
|
||||
Do not collapse lineage across raw sources. A WebUI session that continues
|
||||
from a Telegram/CLI/etc. parent must remain visible as its own surface-owned
|
||||
conversation; otherwise the tip inherits the root's title/source metadata and
|
||||
can disappear under messaging/sidebar policies.
|
||||
|
||||
Explicit branches/delegates never continue: a child whose ``model_config``
|
||||
``_branched_from`` / ``_delegate_from`` points at this parent is a fork of
|
||||
the conversation and must stay visible. Beyond the bounded tolerance window
|
||||
there is no reliable continuation signal — titles are user-controlled and
|
||||
non-unique, so they are never used to widen the window (#7021 re-gate).
|
||||
"""
|
||||
if not parent or not child:
|
||||
return False
|
||||
if str(child.get('session_source') or '').strip().lower() == 'fork':
|
||||
return False
|
||||
# Real Agent branches/delegates are marked in model_config (not
|
||||
# session_source, which only WebUI-created forks carry). The marker must
|
||||
# reference THIS parent: compression continuations inherit the rotated
|
||||
# agent's model_config verbatim, so presence alone (a delegate's
|
||||
# continuation still carries the delegate's own ``_delegate_from``) would
|
||||
# misclassify real continuations.
|
||||
child_branched_from, child_delegate_from = _branch_markers(child)
|
||||
parent_id = parent.get('id')
|
||||
if parent_id and (
|
||||
child_branched_from == parent_id or child_delegate_from == parent_id
|
||||
):
|
||||
return False
|
||||
parent_source = str(parent.get('source') or '').strip().lower()
|
||||
child_source = str(child.get('source') or '').strip().lower()
|
||||
if parent_source and child_source and parent_source != child_source:
|
||||
@@ -349,9 +440,16 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
# continuations when no boundary timestamp is available.
|
||||
return True
|
||||
try:
|
||||
return float(child.get('started_at') or 0) >= float(ended_at)
|
||||
child_started = float(child.get('started_at') or 0)
|
||||
parent_ended = float(ended_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS:
|
||||
return True
|
||||
# Beyond the bounded early-side window the child is a genuine concurrent
|
||||
# session — an exact title match is not evidence (titles are user-controlled
|
||||
# and non-unique, so it cannot extend the tolerance).
|
||||
return False
|
||||
|
||||
|
||||
def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None:
|
||||
@@ -549,21 +647,7 @@ def read_importable_agent_session_rows(
|
||||
return []
|
||||
|
||||
log = log or logger
|
||||
# Open read-only for this projection/listing path: it is a pure read, and
|
||||
# holding a write-capable handle on the live (multi-GB, WAL) state.db while
|
||||
# the agent streams into it adds needless checkpoint/lock surface (#5455).
|
||||
# The defensive index self-heal below still runs, but through a separate
|
||||
# short-lived writable connection on the rare missing-index path only.
|
||||
read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro"
|
||||
try:
|
||||
conn = sqlite3.connect(read_only_uri, uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
log.warning(
|
||||
"agent session listing read-only open failed for %s; falling back to writable connection: %s",
|
||||
db_path,
|
||||
exc,
|
||||
)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn = open_state_db_readonly(db_path, log=log)
|
||||
with closing(conn):
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
@@ -588,6 +672,7 @@ def read_importable_agent_session_rows(
|
||||
|
||||
parent_expr = _optional_col('parent_session_id', session_cols)
|
||||
session_source_expr = _optional_col('session_source', session_cols)
|
||||
model_config_expr = _optional_col('model_config', session_cols)
|
||||
ended_expr = _optional_col('ended_at', session_cols)
|
||||
end_reason_expr = _optional_col('end_reason', session_cols)
|
||||
user_id_expr = _optional_col('user_id', session_cols)
|
||||
@@ -613,11 +698,7 @@ def read_importable_agent_session_rows(
|
||||
use_messages_join = messages_has_session_id
|
||||
count_col = 'id' if 'id' in message_cols else 'session_id'
|
||||
|
||||
# Defensive index prime (#3887). The normal candidate-ordering shape uses
|
||||
# the agent's standard ``idx_messages_session ON messages(session_id,
|
||||
# timestamp)`` index; without it, large cron-only scans degrade badly.
|
||||
# Writable dbs self-heal by recreating the index. Read-only or locked dbs
|
||||
# fall back to the pre-aggregated cron-only path below instead of failing.
|
||||
# Index creation belongs to explicit drained maintenance, never a read.
|
||||
messages_index_present = False
|
||||
if messages_has_session_id and messages_has_timestamp:
|
||||
try:
|
||||
@@ -625,21 +706,7 @@ def read_importable_agent_session_rows(
|
||||
messages_index_present = any(str(row[1]) == "idx_messages_session" for row in cur.fetchall())
|
||||
except sqlite3.Error:
|
||||
messages_index_present = False
|
||||
if not messages_index_present:
|
||||
# Self-heal via a separate writable connection so the common
|
||||
# (index-present) path keeps its read-only handle. On a truly
|
||||
# read-only/locked db this fails and we degrade to the
|
||||
# pre-aggregated cron-only path below, exactly as before.
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as _heal:
|
||||
_heal.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_session "
|
||||
"ON messages(session_id, timestamp)"
|
||||
)
|
||||
_heal.commit()
|
||||
messages_index_present = True
|
||||
except sqlite3.Error:
|
||||
pass # read-only db / locked / older schema — degrade gracefully
|
||||
|
||||
|
||||
if use_messages_join:
|
||||
actual_count_expr = f"COUNT(m.{count_col})"
|
||||
@@ -665,7 +732,6 @@ def read_importable_agent_session_rows(
|
||||
|
||||
where_clauses = ["s.source IS NOT NULL"]
|
||||
params: list[object] = []
|
||||
included = ()
|
||||
if include_sources:
|
||||
included = tuple(str(source) for source in include_sources if source)
|
||||
if included:
|
||||
@@ -679,10 +745,16 @@ def read_importable_agent_session_rows(
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(excluded)
|
||||
|
||||
# Without ``idx_messages_session`` the correlated ``MAX(mx.timestamp)``
|
||||
# candidate ordering rescans ``messages`` once per session (seconds on
|
||||
# a few thousand sessions). Every missing-index projection — default
|
||||
# sidebar, gateway watcher, cron/webhook/kanban views — orders through
|
||||
# one read-only pre-aggregation pass instead; the listing never creates
|
||||
# the index itself (that is drained maintenance, see
|
||||
# ``scripts/ensure_state_db_read_indexes.py``).
|
||||
use_preaggregated_candidate_order = (
|
||||
use_messages_join
|
||||
and messages_has_timestamp
|
||||
and included == ("cron",)
|
||||
and not messages_index_present
|
||||
)
|
||||
if use_preaggregated_candidate_order:
|
||||
@@ -709,6 +781,7 @@ def read_importable_agent_session_rows(
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
{session_source_expr},
|
||||
{model_config_expr},
|
||||
{user_id_expr},
|
||||
{chat_id_expr},
|
||||
{chat_type_expr},
|
||||
@@ -791,6 +864,11 @@ def read_importable_agent_session_rows(
|
||||
params,
|
||||
)
|
||||
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
|
||||
# model_config is selected only so the continuation classifier can
|
||||
# read the real _branched_from/_delegate_from markers; it is internal
|
||||
# agent state and must not leak into the /api/sessions response.
|
||||
for row in projected:
|
||||
row.pop('model_config', None)
|
||||
projected = [_with_normalized_source(row) for row in projected]
|
||||
projected = [row for row in projected if is_cli_session_row_visible(row)]
|
||||
if limit is None:
|
||||
@@ -893,6 +971,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
|
||||
source_expr = _optional_col('source', session_cols)
|
||||
session_source_expr = _optional_col('session_source', session_cols)
|
||||
model_config_expr = _optional_col('model_config', session_cols)
|
||||
title_expr = _optional_col('title', session_cols)
|
||||
started_expr = _optional_col('started_at', session_cols, '0')
|
||||
ended_expr = _optional_col('ended_at', session_cols)
|
||||
@@ -907,6 +986,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
SELECT s.id,
|
||||
{source_expr},
|
||||
{session_source_expr},
|
||||
{model_config_expr},
|
||||
{title_expr},
|
||||
{started_expr},
|
||||
{parent_expr},
|
||||
@@ -953,6 +1033,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
SELECT s.id,
|
||||
{source_expr},
|
||||
{session_source_expr},
|
||||
{model_config_expr},
|
||||
{title_expr},
|
||||
{started_expr},
|
||||
{parent_expr},
|
||||
@@ -1030,6 +1111,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
|
||||
return {}
|
||||
session_source_expr = _optional_col('session_source', session_cols)
|
||||
source_expr = _optional_col('source', session_cols)
|
||||
model_config_expr = _optional_col('model_config', session_cols)
|
||||
message_count_expr = _optional_col('message_count', session_cols, '0')
|
||||
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
|
||||
# full table scan. The sessions table grows unbounded over time
|
||||
@@ -1066,7 +1148,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
FROM sessions s
|
||||
WHERE s.id IN ({placeholders})
|
||||
""",
|
||||
@@ -1095,7 +1177,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
FROM sessions s
|
||||
WHERE s.parent_session_id IN ({placeholders})
|
||||
""",
|
||||
|
||||
+37
@@ -17,6 +17,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import STATE_DIR, get_config, load_settings
|
||||
from api.helpers import request_declares_body
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1207,6 +1208,42 @@ def check_auth(handler, parsed) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def check_auth_or_close(handler, parsed) -> bool:
|
||||
"""Check auth; when rejected, close so an unread body can't poison HTTP/1.1 reuse.
|
||||
|
||||
The flag is armed BEFORE check_auth() writes its 401/302, so end_headers()
|
||||
can advertise ``Connection: close``; success restores the prior flag so an
|
||||
authenticated request keeps its keep-alive.
|
||||
|
||||
Armed ONLY when the request's framing declares a body still queued in
|
||||
``rfile`` (see ``request_declares_body()``), which is the same rule the other
|
||||
reject-before-read sites apply through ``arm_connection_close_if_body_pending()``.
|
||||
Arming unconditionally was the mirror image of the over-close this PR already
|
||||
fixed on the sidecar path: a body-less POST that failed auth answered
|
||||
``401`` WITH ``Connection: close`` and dropped the client's pipelined
|
||||
follow-up, killing a healthy keep-alive connection for no framing reason.
|
||||
Verified on the wire, pipelined down one socket against the production
|
||||
handler: ``POST /api/session/new`` with no ``Content-Length`` (and with
|
||||
``Content-Length: 0``) answered ``401`` + ``Connection: close`` and the
|
||||
following ``GET /api/auth/status`` was never served. The helper cannot be
|
||||
swapped in directly here because the arming has to happen before
|
||||
``check_auth()`` writes its response and be undone if it succeeds.
|
||||
|
||||
``close_connection`` only exists once BaseHTTPRequestHandler has parsed a
|
||||
request line, so partially-built handler stubs may not have it at all. Read
|
||||
it defensively and only arm/restore when it was really there: inventing the
|
||||
attribute on a stub would leave a bogus keep-alive verdict behind.
|
||||
"""
|
||||
if not hasattr(handler, 'close_connection') or not request_declares_body(handler):
|
||||
return check_auth(handler, parsed)
|
||||
prior_close = handler.close_connection
|
||||
handler.close_connection = True
|
||||
if check_auth(handler, parsed):
|
||||
handler.close_connection = prior_close
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_loopback(addr: str) -> bool:
|
||||
"""Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x)."""
|
||||
import ipaddress as _ipaddress
|
||||
|
||||
@@ -1020,6 +1020,29 @@ def _record_async_delegation_accepted(
|
||||
)
|
||||
|
||||
|
||||
def _wakeup_refusal_is_transient(resp: dict | None, status: int) -> bool:
|
||||
"""True when the target refused the wake-up for a reason it will recover from.
|
||||
|
||||
Such refusals are not delivery failures of the completion itself, so they
|
||||
must not consume its bounded durable delivery budget. Otherwise a burst of
|
||||
refusals (e.g. ``agent_runtime_stale`` answers while the Agent runtime is
|
||||
being replaced) terminally drops a completion whose origin session is alive:
|
||||
|
||||
* any payload that explicitly says ``retryable: True`` (stale runtime);
|
||||
* the paused-wakeup contract (``process_wakeup_paused``);
|
||||
* a session busy with another turn (``active_stream_id`` on a 409).
|
||||
"""
|
||||
if not isinstance(resp, dict):
|
||||
return False
|
||||
if resp.get("retryable") is True:
|
||||
return True
|
||||
if status != 409:
|
||||
return False
|
||||
if resp.get("error") == "process_wakeup_paused":
|
||||
return True
|
||||
return bool(resp.get("active_stream_id"))
|
||||
|
||||
|
||||
def _start_async_delegation_wakeup_turn(
|
||||
session_id: str,
|
||||
wakeup_prompt: str,
|
||||
@@ -1059,6 +1082,10 @@ def _start_async_delegation_wakeup_turn(
|
||||
)
|
||||
return
|
||||
|
||||
transient = _wakeup_refusal_is_transient(resp, status)
|
||||
if transient:
|
||||
release_async_delegation_delivery(evt, claim, retryable=True)
|
||||
else:
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
@@ -1068,6 +1095,14 @@ def _start_async_delegation_wakeup_turn(
|
||||
"async delegation wakeup paused for session %s; delivery remains retryable",
|
||||
session_id,
|
||||
)
|
||||
elif transient:
|
||||
logger.info(
|
||||
"async delegation wakeup refused transiently for session %s "
|
||||
"(status=%s err=%r); attempt refunded, durable retry scheduled",
|
||||
session_id,
|
||||
status,
|
||||
(resp or {}).get("error"),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"async delegation wakeup not accepted for session %s: "
|
||||
@@ -1848,7 +1883,22 @@ def recover_processes_for_webui(process_registry=None, get_session_fn=None) -> i
|
||||
try:
|
||||
proc_session = process_registry.get(process_id)
|
||||
session_key = str(getattr(proc_session, "session_key", "") or "")
|
||||
if not session_key or get_session_fn(session_key, metadata_only=True) is None:
|
||||
if not session_key:
|
||||
continue
|
||||
# The session resolver (``api.models._resolve_session_once``)
|
||||
# raises ``KeyError(sid)`` for a missing session — that is its
|
||||
# documented contract for the "owner gone" outcome, not a
|
||||
# fault. Treat it identically to a ``None`` return: skip the
|
||||
# process without logging, and let a neighbouring live
|
||||
# process still rebind. The narrow ``except KeyError`` lives
|
||||
# INSIDE the outer try so an unrelated ``KeyError`` from
|
||||
# ``process_registry.get()`` (or any routing-index work) still
|
||||
# surfaces in the existing warning path.
|
||||
try:
|
||||
resolved = get_session_fn(session_key, metadata_only=True)
|
||||
except KeyError:
|
||||
continue
|
||||
if resolved is None:
|
||||
continue
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Read-only routing hints for externally compressed WebUI sessions.
|
||||
|
||||
SQLite owns compression lineage; sidecar snapshot flags may predate a
|
||||
Desktop/CLI rotation. Never reopen a sealed parent or mutate Agent state here.
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_local_interactive_persisted_source(source):
|
||||
return isinstance(source, str) and source in {
|
||||
"webui", "tui", "cli", "desktop", "acp",
|
||||
}
|
||||
|
||||
|
||||
def durable_compression_continuation(session):
|
||||
"""Return (sealed, resumable tip), without making a recovery write.
|
||||
|
||||
A known sealed parent without a safe tip stays sealed (no sidecar fallback).
|
||||
Older Agent installations without this read API retain legacy behavior.
|
||||
"""
|
||||
from api.profiles import _PROFILE_ID_RE, _resolve_profile_home_for_name
|
||||
|
||||
sid = str(getattr(session, "session_id", "") or "")
|
||||
profile = str(getattr(session, "profile", None) or "default")
|
||||
if not sid or (profile != "default" and not _PROFILE_ID_RE.fullmatch(profile)):
|
||||
return False, None
|
||||
db = None
|
||||
sealed = False
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
path = Path(_resolve_profile_home_for_name(profile)) / "state.db"
|
||||
if not path.is_file():
|
||||
return False, None
|
||||
db = SessionDB(path, read_only=True)
|
||||
# Establish the complete read API before accepting SQLite authority.
|
||||
# Old Agents must retain legacy sidecar recovery, not a sealed null tip.
|
||||
for name in ('get_session', 'get_compression_tip'):
|
||||
method = getattr(db, name, None)
|
||||
if not callable(method):
|
||||
return False, None
|
||||
try:
|
||||
inspect.signature(method).bind(sid)
|
||||
except (TypeError, ValueError):
|
||||
return False, None
|
||||
parent = db.get_session(sid)
|
||||
if not parent or parent.get("end_reason") != "compression":
|
||||
return False, None
|
||||
sealed = True
|
||||
tip = db.get_compression_tip(sid)
|
||||
if not tip or tip == sid:
|
||||
return True, None
|
||||
child = db.get_session(tip)
|
||||
if not child or not _is_local_interactive_persisted_source(child.get("source")):
|
||||
return True, None
|
||||
for row in (parent, child):
|
||||
if row.get("profile_name") not in (None, "", profile):
|
||||
return True, None
|
||||
# Only the observed automatic idle closure is resumable here. Explicit
|
||||
# resets/closures and unknown future reasons must not become redirects.
|
||||
reason = child.get("end_reason")
|
||||
if reason not in (None, "", "idle_timeout"):
|
||||
return True, None
|
||||
if child.get("ended_at") is not None and reason != "idle_timeout":
|
||||
return True, None
|
||||
if db.get_compression_tip(sid) != tip:
|
||||
return True, None
|
||||
return True, tip
|
||||
except (ImportError, AttributeError, TypeError):
|
||||
return sealed, None
|
||||
except Exception:
|
||||
logger.debug("Could not resolve durable compression continuation", exc_info=True)
|
||||
return sealed, None
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
+98
-21
@@ -68,6 +68,21 @@ def _env_int(name: str, default: int, *, minimum: int = 1) -> int:
|
||||
return default
|
||||
return value if value >= minimum else default
|
||||
|
||||
|
||||
def _env_int_clamped(name: str, default: int, *, minimum: int = 1, maximum: int) -> int:
|
||||
"""Like ``_env_int``, then clamp a valid override to ``maximum``."""
|
||||
value = _env_int(name, default, minimum=minimum)
|
||||
if not str(os.getenv(name) or "").strip():
|
||||
return value
|
||||
return min(value, maximum)
|
||||
|
||||
|
||||
# Sidebar recency window. Resolved here, before profile init, so a profile
|
||||
# .env cannot override a server-wide resource bound. Clamped at 200.
|
||||
CLI_VISIBLE_SESSION_LIMIT = _env_int_clamped(
|
||||
"HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20, maximum=200,
|
||||
)
|
||||
|
||||
# ── TLS/HTTPS config (optional, env-overridable) ────────────────────────────
|
||||
TLS_CERT = os.getenv("HERMES_WEBUI_TLS_CERT", "").strip() or None
|
||||
TLS_KEY = os.getenv("HERMES_WEBUI_TLS_KEY", "").strip() or None
|
||||
@@ -1914,28 +1929,44 @@ _PROVIDER_MODELS = {
|
||||
{"id": "big-pickle", "label": "Big Pickle"},
|
||||
],
|
||||
# OpenCode Go — flat-rate models via opencode.ai/go ($10/month).
|
||||
# Synced 2026-07-08 from the public Go docs and documented models endpoint.
|
||||
# Keep preview/free-only Zen models out of this Go picker snapshot.
|
||||
# Fallback only: the live Hermes CLI catalog (Go-specific
|
||||
# /zen/go/v1/models probe, core v0.20.5+) leads (#1240, #5311).
|
||||
# Mirrors Hermes core's curated opencode-go list
|
||||
# (hermes_cli/models_catalog_static.py, core main as of 2026-09-10).
|
||||
# Core's 2026-09-09 sync dropped `ox-alpha-free` (Go relay delisted it:
|
||||
# GET /zen/go/v1/models omits it, POST → 401) and added `glm-5.3-flash`
|
||||
# and `muse-spark-1.3-contributor`. Core owns the sync duty against the
|
||||
# live endpoint; WebUI mirrors.
|
||||
"opencode-go": [
|
||||
{"id": "minimax-m3", "label": "MiniMax M3"},
|
||||
{"id": "minimax-m2.7", "label": "MiniMax M2.7"},
|
||||
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
|
||||
{"id": "kimi-k3", "label": "Kimi K3"},
|
||||
{"id": "kimi-k2.7-code", "label": "Kimi K2.7 Code"},
|
||||
{"id": "kimi-k2.6", "label": "Kimi K2.6"},
|
||||
{"id": "kimi-k2.5", "label": "Kimi K2.5"},
|
||||
{"id": "gpt-5.6-luna", "label": "GPT 5.6 Luna"},
|
||||
{"id": "grok-4.5", "label": "Grok 4.5"},
|
||||
{"id": "glm-5.3", "label": "GLM-5.3"},
|
||||
{"id": "glm-5.3-flash", "label": "GLM-5.3 Flash"},
|
||||
{"id": "glm-5.2", "label": "GLM-5.2"},
|
||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||
{"id": "glm-5", "label": "GLM-5"},
|
||||
{"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"},
|
||||
{"id": "mimo-v2.5", "label": "MiMo V2.5"},
|
||||
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
|
||||
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
|
||||
{"id": "minimax-m3", "label": "MiniMax M3"},
|
||||
{"id": "minimax-m2.7", "label": "MiniMax M2.7"},
|
||||
{"id": "minimax-m2.5", "label": "MiniMax M2.5"},
|
||||
{"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro"},
|
||||
{"id": "deepseek-v4-flash","label": "DeepSeek V4 Flash"},
|
||||
{"id": "deepseek-v4-flash", "label": "DeepSeek V4 Flash"},
|
||||
{"id": "qwen3.8-max", "label": "Qwen3.8 Max"},
|
||||
{"id": "qwen3.7-max", "label": "Qwen3.7 Max"},
|
||||
{"id": "qwen3.7-plus", "label": "Qwen3.7 Plus"},
|
||||
{"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"},
|
||||
{"id": "qwen3.5-plus", "label": "Qwen3.5 Plus"},
|
||||
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
|
||||
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
|
||||
{"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"},
|
||||
{"id": "mimo-v2.5", "label": "MiMo V2.5"},
|
||||
{"id": "hy3", "label": "HY3"},
|
||||
{"id": "hy3-preview", "label": "HY3 Preview"},
|
||||
{"id": "muse-spark-1.2-contributor", "label": "Muse Spark 1.2 Contributor"},
|
||||
{"id": "muse-spark-1.3-contributor", "label": "Muse Spark 1.3 Contributor"},
|
||||
],
|
||||
# 'gemini' is the hermes_cli provider ID for Google AI Studio
|
||||
# Model IDs are bare — sent directly to:
|
||||
@@ -6937,8 +6968,8 @@ def _configured_model_badges_from_static_catalog(
|
||||
for entry in configured_entries:
|
||||
provider = entry["provider"]
|
||||
model = entry["model"]
|
||||
raw_candidates = []
|
||||
for candidate in (model, f"{provider}/{model}", f"@{provider}:{model}"):
|
||||
raw_candidates: list[str] = []
|
||||
for candidate in (model, f"@{provider}:{model}"):
|
||||
if candidate and candidate not in raw_candidates:
|
||||
raw_candidates.append(candidate)
|
||||
|
||||
@@ -8475,6 +8506,26 @@ def _read_live_provider_model_ids(provider_id: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _hermes_cli_supports_opencode_go_live_catalog() -> bool:
|
||||
"""Whether the installed Agent has Go-specific model discovery.
|
||||
|
||||
Hermes core versions before 0.20.5 route ``opencode-go`` through a
|
||||
generic public catalog. That lookup can return a convincing non-empty
|
||||
list containing models the Go relay rejects with 404, so absence or an
|
||||
unparseable/prerelease version must fail closed to WebUI's static Go list.
|
||||
"""
|
||||
try:
|
||||
import hermes_cli
|
||||
|
||||
version = str(getattr(hermes_cli, "__version__", "")).strip()
|
||||
except Exception:
|
||||
return False
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", version)
|
||||
if not match:
|
||||
return False
|
||||
return tuple(int(part) for part in match.groups()) >= (0, 20, 5)
|
||||
|
||||
|
||||
def _models_from_live_provider_ids(provider_id: str, live_ids: list[str]) -> list[dict]:
|
||||
"""Convert Hermes CLI model ids into WebUI picker model entries."""
|
||||
formatter = _format_ollama_label if provider_id in ("ollama", "ollama-cloud") else None
|
||||
@@ -8683,10 +8734,9 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
||||
for entry in configured_entries:
|
||||
provider = entry["provider"]
|
||||
model = entry["model"]
|
||||
raw_candidates = []
|
||||
raw_candidates: list[str] = []
|
||||
for candidate in (
|
||||
model,
|
||||
f"{provider}/{model}",
|
||||
f"@{provider}:{model}",
|
||||
):
|
||||
if candidate and candidate not in raw_candidates:
|
||||
@@ -9872,12 +9922,14 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
||||
if not raw_models:
|
||||
if pid == "moa":
|
||||
raw_models = _moa_preset_models_from_config(cfg)
|
||||
elif pid == "opencode-go":
|
||||
# Skip live /v1/models probe for OpenCode Go — it
|
||||
# returns models from the public catalog that are
|
||||
# not enabled on the Go tier, causing 404 when
|
||||
# selected. Use the curated static list only. (#5311)
|
||||
pass
|
||||
elif (
|
||||
pid == "opencode-go"
|
||||
and not _hermes_cli_supports_opencode_go_live_catalog()
|
||||
):
|
||||
# Before core v0.20.5 this resolver returned the
|
||||
# generic public catalog, including models that
|
||||
# 404 on the Go tier. Use the curated Go fallback.
|
||||
raw_models = []
|
||||
else:
|
||||
raw_models = _models_from_live_provider_ids(
|
||||
pid,
|
||||
@@ -11532,7 +11584,13 @@ _SETTINGS_DEFAULTS = {
|
||||
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
|
||||
"tab_order": [], # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
|
||||
"composer_control_order": [], # user-defined composer footer control order; invalid/duplicate keys are ignored
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
# #7622 (round 3): language is intentionally absent from the defaults so
|
||||
# `load_settings()` reports `None` for a fresh install. This lets the
|
||||
# client distinguish "no preference" from an explicit saved choice,
|
||||
# so a user who genuinely picked English (and has `language: "en"`
|
||||
# persisted on disk) is no longer overridden by the browser hint on
|
||||
# their first hydration. When a user picks a locale in the Settings
|
||||
# modal the value is written here and the field is set explicitly.
|
||||
"bot_name": os.getenv(
|
||||
"HERMES_WEBUI_BOT_NAME", "Hermes"
|
||||
), # display name for the assistant
|
||||
@@ -11772,6 +11830,16 @@ def load_settings() -> dict:
|
||||
settings["default_model_provider"] = str(model_cfg.get("provider"))
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve default model provider for settings")
|
||||
# #7622/#7730 (round 4): keep the tri-state signal explicit. `language`
|
||||
# is intentionally absent from `_SETTINGS_DEFAULTS` (see above), so
|
||||
# without this line a fresh install's returned dict would OMIT the key
|
||||
# entirely and the API payload would carry no `language` field. Emit an
|
||||
# explicit `None` (serialized as JSON `null`) so the client receives the
|
||||
# three-way signal it trusts: `null` = no preference, "en" = explicitly
|
||||
# saved English, any other code = explicitly chosen locale. Stored
|
||||
# values (including a legacy English pick written before this change)
|
||||
# win via the merge above and are never touched here.
|
||||
settings.setdefault("language", None)
|
||||
return settings
|
||||
|
||||
|
||||
@@ -11779,6 +11847,15 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
|
||||
"password_hash",
|
||||
"default_model",
|
||||
"simplified_tool_calling",
|
||||
} | {
|
||||
# #7622 (round 3): `language` is intentionally absent from
|
||||
# `_SETTINGS_DEFAULTS` so a fresh install returns `None` for
|
||||
# `settings["language"]` and the client can distinguish "no
|
||||
# preference" from an explicit saved choice. But the user
|
||||
# MUST still be able to pick a locale in the Settings modal,
|
||||
# so we add it back to the explicit allow-list here. The
|
||||
# existing BCP-47 validation at save-time still applies.
|
||||
"language",
|
||||
}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
"send_key": {"enter", "ctrl+enter", "shift+enter"},
|
||||
|
||||
+30
-11
@@ -153,6 +153,9 @@ _WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
|
||||
_WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY"
|
||||
_WEBUI_GATEWAY_USE_RUNS_API_ENV = "HERMES_WEBUI_GATEWAY_USE_RUNS_API"
|
||||
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
|
||||
# Backend tag of the in-process WebUI runtime. Local workers register their
|
||||
# active run with it; cache-only Steer only enqueues on this explicit value.
|
||||
WEBUI_LOCAL_CHAT_BACKEND = "legacy"
|
||||
|
||||
|
||||
def _gateway_model_field(model: str | None) -> str:
|
||||
@@ -272,7 +275,7 @@ def webui_chat_backend_mode(config_data=None, environ: dict[str, str] | None = N
|
||||
).strip().lower()
|
||||
if raw in _GATEWAY_CHAT_BACKENDS:
|
||||
return "gateway"
|
||||
return "legacy"
|
||||
return WEBUI_LOCAL_CHAT_BACKEND
|
||||
|
||||
|
||||
def webui_gateway_chat_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
|
||||
@@ -738,8 +741,12 @@ 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)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
run_id,
|
||||
reason="Gateway run completed before approval resolution",
|
||||
)
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(str(payload["error"]))
|
||||
output = str(payload.get("output") or "")
|
||||
@@ -751,12 +758,20 @@ 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)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
run_id,
|
||||
reason="Gateway run failed before approval resolution",
|
||||
)
|
||||
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)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
run_id,
|
||||
reason="Gateway run was cancelled before approval resolution",
|
||||
)
|
||||
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
|
||||
return None, usage
|
||||
reasoning_delta = _gateway_sse_reasoning_delta(payload)
|
||||
@@ -978,7 +993,7 @@ def _run_gateway_chat_streaming(
|
||||
except Exception:
|
||||
logger.debug("Failed to note gateway event_id %s for stream %s", event_id, stream_id, exc_info=True)
|
||||
try:
|
||||
queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
q.put_nowait(queue_item)
|
||||
except Exception:
|
||||
logger.debug("Failed to put gateway event to queue")
|
||||
@@ -1464,10 +1479,14 @@ def _run_gateway_chat_streaming(
|
||||
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)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
mapped_run_id,
|
||||
reason="Gateway run ended during teardown before approval resolution",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to retire gateway pending mirrors during teardown", exc_info=True)
|
||||
logger.debug("Failed to settle gateway pending approvals during teardown", exc_info=True)
|
||||
if s is not None:
|
||||
try:
|
||||
with _get_session_agent_lock(session_id):
|
||||
|
||||
+63
-17
@@ -203,7 +203,11 @@ class GatewayWatcher:
|
||||
):
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._sub_lock = threading.Lock()
|
||||
# Final removal invalidates any projection admitted by an earlier cohort.
|
||||
self._subscriber_epoch = 0
|
||||
self._stop_event = threading.Event()
|
||||
# Wakes a poll loop parked because nobody is subscribed (subscribe/stop).
|
||||
self._idle_wake = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._hermes_home = Path(hermes_home).expanduser().resolve() if hermes_home else None
|
||||
self._state_db_path = (
|
||||
@@ -244,6 +248,7 @@ class GatewayWatcher:
|
||||
def stop(self):
|
||||
"""Stop the watcher thread."""
|
||||
self._stop_event.set()
|
||||
self._idle_wake.set() # unpark if waiting for the first subscriber
|
||||
# Wake up any subscribers
|
||||
with self._sub_lock:
|
||||
for q in self._subscribers:
|
||||
@@ -255,6 +260,11 @@ class GatewayWatcher:
|
||||
self._thread.join(timeout=3)
|
||||
self._thread = None
|
||||
|
||||
def _has_subscribers(self) -> bool:
|
||||
"""Return True when at least one SSE client is attached."""
|
||||
with self._sub_lock:
|
||||
return bool(self._subscribers)
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
"""Subscribe to change events. Returns a queue.Queue.
|
||||
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
|
||||
@@ -273,23 +283,37 @@ class GatewayWatcher:
|
||||
q.put_nowait(None)
|
||||
except Exception:
|
||||
logger.debug("Failed to send stop sentinel to late subscriber")
|
||||
# Wake a poll loop parked with zero subscribers so the first SSE client
|
||||
# gets a prompt initial projection instead of waiting out POLL_INTERVAL.
|
||||
self._idle_wake.set()
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
"""Remove a subscriber queue."""
|
||||
with self._sub_lock:
|
||||
def _remove_subscriber_locked(self, q: queue.Queue) -> bool:
|
||||
"""Remove a known queue under _sub_lock; fence polls on the last removal."""
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
if not self._subscribers:
|
||||
self._subscriber_epoch += 1
|
||||
self._last_cheap_fp = ''
|
||||
self._last_full_projection_at = None
|
||||
return True
|
||||
|
||||
def _notify_subscribers(self, sessions: list):
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
"""Remove a subscriber queue, invalidating projection on the last removal."""
|
||||
with self._sub_lock:
|
||||
self._remove_subscriber_locked(q)
|
||||
|
||||
def _notify_subscribers(self, sessions: list, *, epoch: int | None = None):
|
||||
"""Push change event to all subscribers."""
|
||||
event = {
|
||||
'type': 'sessions_changed',
|
||||
'sessions': sessions,
|
||||
}
|
||||
with self._sub_lock:
|
||||
if epoch is not None and epoch != self._subscriber_epoch:
|
||||
return # An old poll must not notify a new subscriber cohort.
|
||||
dead = []
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
@@ -299,10 +323,7 @@ class GatewayWatcher:
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
self._remove_subscriber_locked(q)
|
||||
# Send a None sentinel so the SSE handler unblocks, closes,
|
||||
# and lets the browser's EventSource auto-reconnect.
|
||||
try:
|
||||
@@ -317,6 +338,12 @@ class GatewayWatcher:
|
||||
protects projection fields (notably role-derived CLI visibility) that
|
||||
the agent's existing index cannot see.
|
||||
"""
|
||||
with self._sub_lock:
|
||||
has_subscribers = bool(self._subscribers)
|
||||
admission_epoch = self._subscriber_epoch
|
||||
if not has_subscribers:
|
||||
# Final removal already invalidated the cache under the same lock.
|
||||
return False
|
||||
db_path = self._state_db_path
|
||||
# A watcher may start before the agent has created state.db. Publishing an
|
||||
# empty first snapshot would make an already-rendered sidebar disappear;
|
||||
@@ -344,29 +371,48 @@ class GatewayWatcher:
|
||||
if sessions is None:
|
||||
return False
|
||||
current_hash = _snapshot_hash(sessions)
|
||||
with self._sub_lock:
|
||||
if admission_epoch != self._subscriber_epoch or not self._subscribers:
|
||||
return False # Never restore a cache invalidated during the DB read.
|
||||
if cheap_fp is not None:
|
||||
self._last_cheap_fp = cheap_fp
|
||||
self._last_full_projection_at = current_time
|
||||
|
||||
if current_hash != self._last_hash:
|
||||
changed = True
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
self._notify_subscribers(sessions)
|
||||
else:
|
||||
changed = False
|
||||
if changed:
|
||||
self._notify_subscribers(sessions, epoch=admission_epoch)
|
||||
return True
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop. Runs in a daemon thread."""
|
||||
"""Main polling loop. Runs in a daemon thread.
|
||||
|
||||
With no SSE subscribers there is nobody to notify, so the loop parks on
|
||||
``_idle_wake`` instead of re-fingerprinting ``state.db`` every few
|
||||
seconds (maint #3035). While subscribed it blocks once on
|
||||
``_stop_event.wait(POLL_INTERVAL)`` — a single timer sleep that returns
|
||||
immediately when ``stop()`` sets the event, replacing the previous
|
||||
10 wakeups/sec ``time.sleep(0.1)`` spin.
|
||||
"""
|
||||
while not self._stop_event.is_set():
|
||||
if not self._has_subscribers():
|
||||
self._idle_wake.clear()
|
||||
# subscribe() may have raced between the check and clear; if so
|
||||
# the flag is already set again and we must not park.
|
||||
if self._has_subscribers() or self._stop_event.is_set():
|
||||
continue
|
||||
self._idle_wake.wait()
|
||||
continue
|
||||
|
||||
try:
|
||||
self._poll_once()
|
||||
except Exception:
|
||||
logger.debug("Error in gateway watcher poll loop", exc_info=True)
|
||||
|
||||
# Sleep in small increments so we can stop promptly
|
||||
for _ in range(self.POLL_INTERVAL * 10):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
self._stop_event.wait(self.POLL_INTERVAL)
|
||||
|
||||
|
||||
# ── Module-level watcher registry ──────────────────────────────────────────
|
||||
|
||||
+242
@@ -202,6 +202,248 @@ def _security_headers(handler):
|
||||
)
|
||||
|
||||
|
||||
def arm_connection_close(handler) -> None:
|
||||
"""Mark the connection dead BEFORE a reject-before-read response is written.
|
||||
|
||||
A rejection that answers without consuming the request body leaves those
|
||||
bytes queued in ``rfile``; reusing the connection makes the next HTTP/1.1
|
||||
request parse mid-body (the classic ``{}GET ... -> 501``). Arming must
|
||||
happen before the response is flushed so ``advertise_connection_close()``
|
||||
can serialize it into the response headers.
|
||||
|
||||
Best-effort: handler stubs and read-only doubles may reject the attribute,
|
||||
and a failure here must never turn a 4xx into a 500.
|
||||
"""
|
||||
try:
|
||||
handler.close_connection = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# What ``unsupported_transfer_encoding()`` reports for a header that declares no
|
||||
# coding at all (``Transfer-Encoding:``). A placeholder rather than the raw '' so
|
||||
# the return value is never a falsy non-``None`` — callers test ``is not None``,
|
||||
# and an empty string would also render as a hole in the rejection message.
|
||||
_BLANK_TRANSFER_CODING = '(blank)'
|
||||
|
||||
# The only whitespace a field value may be padded with: RFC 9110 OWS is SP/HTAB
|
||||
# and nothing else. ``str.strip()`` would also erase U+00A0 and U+0085 -- both
|
||||
# reachable over the wire, because request lines decode as latin-1 -- and
|
||||
# ``Content-Length: \xa00`` would then read back as a genuine ``0`` while its
|
||||
# payload sat unread on the socket. Padding a value with a character the grammar
|
||||
# does not allow makes it unreadable, not zero.
|
||||
_FIELD_VALUE_OWS = ' \t'
|
||||
|
||||
|
||||
def _framing_header_values(handler, name: str) -> list[str]:
|
||||
"""Every value the wire carried for one framing header, tolerating stubs.
|
||||
|
||||
ALL values, not just the first: ``Message.get()`` returns only the first
|
||||
occurrence, so a request carrying ``Content-Length: 0`` followed by
|
||||
``Content-Length: 42`` reads back as length zero. Its 42 body bytes are then
|
||||
never consumed and get parsed as the next request line -- the same poisoning
|
||||
this module exists to prevent, reached by duplicating a header rather than by
|
||||
omitting one. Conflicting framing is only visible if every value is.
|
||||
|
||||
A real handler carries an ``email.message.Message`` (case-insensitive
|
||||
``get_all()``, like the wire); the suite's ``SimpleNamespace(headers={...})``
|
||||
doubles are plain dicts, which are not, so fall back to a case-insensitive
|
||||
scan for those. A missing or odd ``headers`` must never raise: every caller
|
||||
is on a rejection path where an exception becomes a spurious 500.
|
||||
"""
|
||||
headers = getattr(handler, 'headers', None)
|
||||
if headers is None:
|
||||
return []
|
||||
try:
|
||||
get_all = getattr(headers, 'get_all', None)
|
||||
if callable(get_all):
|
||||
values = get_all(name)
|
||||
return [] if values is None else [str(v) for v in values if v is not None]
|
||||
if isinstance(headers, dict):
|
||||
lowered = name.lower()
|
||||
return [
|
||||
str(v) for k, v in headers.items()
|
||||
if str(k).lower() == lowered and v is not None
|
||||
]
|
||||
value = headers.get(name)
|
||||
return [] if value is None else [str(value)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _declared_content_lengths(handler) -> set[int] | None:
|
||||
"""Distinct declared ``Content-Length`` values, or ``None`` if any is unreadable.
|
||||
|
||||
``None`` means the framing cannot be trusted at all (a value that will not
|
||||
parse); an empty set means no length was declared.
|
||||
|
||||
A BLANK or whitespace-only value is unreadable, NOT absent. ``Content-Length:``
|
||||
with payload bytes behind it declares a body whose size the header refuses to
|
||||
state; skipping the empty value reported "no length declared", so the
|
||||
rejection kept the connection alive and those bytes were parsed as the next
|
||||
request line (403 then ``400 Bad request syntax ('{...}GET /api/... HTTP/1.1')``).
|
||||
Absence is something only the wire can say, and ``_framing_header_values()``
|
||||
already reports a header the request never carried as no value at all -- so
|
||||
anything that *is* here and does not parse has to be treated as pending bytes,
|
||||
exactly like ``banana`` or two lengths that disagree. Note ``Message`` collapses
|
||||
``Content-Length:`` and ``Content-Length: `` to the same ``''``.
|
||||
|
||||
"Parses" means RFC 9110's ``Content-Length = 1*DIGIT``: an unsigned run of
|
||||
ASCII digits, nothing else. ``int()`` is far more generous than the grammar --
|
||||
it accepts a leading sign, PEP 515 underscores and non-ASCII digits -- and
|
||||
every such spelling of ZERO used to read back as an honest ``0`` and take the
|
||||
keep-alive branch below with its payload still queued: ``Content-Length: +0``
|
||||
(also ``-0``, ``0_0``, ``+00``, ``\\xa00``) answered 403 with no
|
||||
``Connection: close``, then ``400 Bad request syntax ('{...}GET /api/...')``
|
||||
-- the same trace as a blank value. A sign is only invisible on a NON-zero
|
||||
value, where the wrong parse happens to close anyway. Anything the grammar
|
||||
rejects is unreadable framing, which means a body may be pending.
|
||||
|
||||
The ``int()`` guard stays even though ``isdigit()`` has already vetted every
|
||||
character: a digit run longer than ``sys.get_int_max_str_digits()`` (4300)
|
||||
raises ``ValueError`` too, and this runs on rejection paths where an
|
||||
exception becomes a spurious 500.
|
||||
"""
|
||||
parsed: set[int] = set()
|
||||
for raw in _framing_header_values(handler, 'Content-Length'):
|
||||
# RFC 9110 §5.3: a list-valued field may arrive as repeated lines OR as
|
||||
# one comma-combined line, and the two spellings mean the same thing.
|
||||
# So "0, 0" is the same bodyless request as two "Content-Length: 0"
|
||||
# headers and must keep its keep-alive; only a DISAGREEING list (caught
|
||||
# below by len(parsed) > 1) is unframeable. Without the split the comma
|
||||
# fails isdigit() and every agreeing list over-closed a healthy socket.
|
||||
for member in raw.split(','):
|
||||
stripped = member.strip(_FIELD_VALUE_OWS)
|
||||
if not (stripped.isascii() and stripped.isdigit()):
|
||||
return None
|
||||
try:
|
||||
parsed.add(int(stripped))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def unreadable_content_length(handler) -> bool:
|
||||
"""True when ``Content-Length`` cannot be trusted to frame the body.
|
||||
|
||||
Either a value that will not parse (``banana``, ``0x5``, or a BLANK one), or
|
||||
two or more values that disagree. None of them can tell a reader how many
|
||||
bytes to drain, so such a request can only be rejected -- with the connection
|
||||
armed for close, since bytes may still be queued behind it.
|
||||
"""
|
||||
parsed = _declared_content_lengths(handler)
|
||||
return parsed is None or len(parsed) > 1
|
||||
|
||||
|
||||
def request_declares_body(handler) -> bool:
|
||||
"""True when the request's framing declares a body that is still queued.
|
||||
|
||||
Keyed on the wire, not on the method: ``Transfer-Encoding`` present (chunked
|
||||
framing hides the length until the terminating chunk is read) or
|
||||
``Content-Length`` present and non-zero. The method proves nothing in either
|
||||
direction -- a GET may carry a declared body and a POST/PUT/DELETE may carry
|
||||
none -- and a static per-method flag therefore both misses body-bearing
|
||||
"read" requests (whose unread bytes poison the next pooled request) and
|
||||
kills healthy keep-alive on body-less writes.
|
||||
|
||||
A blank, unparseable or self-contradicting ``Content-Length`` counts as a
|
||||
declared body, as does ANY ``Transfer-Encoding`` -- ``identity`` and a blank
|
||||
value included: a value we cannot interpret does not prove the body's absence,
|
||||
and the safe assumption for connection reuse is that bytes are pending. Only
|
||||
framing that positively says "no body" -- no framing header at all, or every
|
||||
declared length agreeing on zero -- keeps the connection alive.
|
||||
"""
|
||||
if unsupported_transfer_encoding(handler) is not None:
|
||||
return True
|
||||
parsed = _declared_content_lengths(handler)
|
||||
if parsed is None:
|
||||
return True
|
||||
return any(length != 0 for length in parsed)
|
||||
|
||||
|
||||
def unsupported_transfer_encoding(handler) -> str | None:
|
||||
"""Return the transfer coding this server cannot decode, or ``None``.
|
||||
|
||||
EVERY present value counts, with no exemption for any coding.
|
||||
``BaseHTTPRequestHandler`` decodes none of them: it hands every reader a raw
|
||||
``rfile``, so a chunked body reads back as its literal chunk framing while a
|
||||
``Content-Length``-based reader sees length 0 and consumes nothing at all.
|
||||
RFC 9112 section 6.3 says the same thing normatively -- a request whose final
|
||||
transfer coding is not ``chunked`` has a body length the recipient cannot
|
||||
determine, so it must be refused and the connection closed. Such a request
|
||||
can therefore only be rejected, and only with the connection armed for close,
|
||||
because its payload bytes are still queued on the socket.
|
||||
|
||||
``identity`` used to be exempt as a coding that "frames nothing". It is not:
|
||||
it names no framing either, and RFC 7230 removed it from the transfer codings
|
||||
altogether. A sidecar rejection then answered 403 with keep-alive and the
|
||||
payload was parsed as the next request line
|
||||
(``400 Bad request syntax ('{...}GET /api/health/agent HTTP/1.1')``), and all
|
||||
four upload handlers fell through to a length-0 read, answered 400 "No file
|
||||
field in request" and left the multipart bytes on the socket
|
||||
(``400 Bad request syntax ('--x')``). Nothing legitimate is lost: no client
|
||||
sends a transfer coding this server could have honoured.
|
||||
|
||||
A header present but EMPTY (``Transfer-Encoding:``) is reported through
|
||||
``_BLANK_TRANSFER_CODING`` rather than as ``''``, so callers testing
|
||||
``is not None`` and the 411 message both read right. Only an ABSENT header
|
||||
yields no values at all -- absence is something only the wire can say.
|
||||
|
||||
The first value is enough precisely because no value is acceptable; it is
|
||||
reported as-is (SP/HTAB trimmed, the one padding RFC 9110 allows) purely so
|
||||
the rejection message names what arrived.
|
||||
"""
|
||||
values = _framing_header_values(handler, 'Transfer-Encoding')
|
||||
if not values:
|
||||
return None
|
||||
return values[0].strip(_FIELD_VALUE_OWS) or _BLANK_TRANSFER_CODING
|
||||
|
||||
|
||||
def arm_connection_close_if_body_pending(handler) -> bool:
|
||||
"""Arm close for a reject-before-read, but only if a body was really declared.
|
||||
|
||||
Returns whether the connection was armed. Use this instead of
|
||||
``arm_connection_close()`` wherever the rejection can also fire on a request
|
||||
with no body: closing then would drop a pooled client's healthy connection
|
||||
for no framing reason.
|
||||
"""
|
||||
if not request_declares_body(handler):
|
||||
return False
|
||||
arm_connection_close(handler)
|
||||
return True
|
||||
|
||||
|
||||
def advertise_connection_close(handler) -> None:
|
||||
"""Serialize ``close_connection`` into exactly one ``Connection: close`` header.
|
||||
|
||||
``BaseHTTPRequestHandler`` tracks ``close_connection`` internally but never
|
||||
writes it to the wire, so a server-side close is invisible to the client: a
|
||||
pooled client reuses the socket and fails with BrokenPipeError instead of
|
||||
opening a fresh connection. Call this from ``end_headers()``, before the
|
||||
header buffer is flushed.
|
||||
|
||||
Dedup is derived from the pending header buffer rather than a private flag,
|
||||
because the SSE endpoints send their own ``Connection: close`` (and
|
||||
``send_header`` itself flips ``close_connection`` when they do) — keying off
|
||||
a flag we set ourselves would emit the header twice on every stream.
|
||||
|
||||
Both attribute reads are defensive: partially-built handler stubs in the
|
||||
test suite carry neither ``close_connection`` nor ``_headers_buffer``, and
|
||||
header emission must not depend on their presence. The buffer is also
|
||||
type-checked before it is scanned -- a stub whose ``_headers_buffer`` is a
|
||||
truthy non-iterable (a bare ``Mock``) would otherwise raise ``TypeError``
|
||||
from inside ``end_headers()``, which ``_handle_write`` would swallow into a
|
||||
spurious 500.
|
||||
"""
|
||||
if not getattr(handler, 'close_connection', False):
|
||||
return
|
||||
buffered = getattr(handler, '_headers_buffer', None)
|
||||
for raw in buffered if isinstance(buffered, (list, tuple)) else ():
|
||||
if raw[:11].lower() == b'connection:':
|
||||
return
|
||||
handler.send_header('Connection', 'close')
|
||||
|
||||
|
||||
def flush_pending_auth_cookies(handler) -> None:
|
||||
pending = getattr(handler, '_pending_set_cookies', None)
|
||||
if not pending:
|
||||
|
||||
@@ -505,6 +505,13 @@ def annotate_media_snapshots(
|
||||
if allowed_predicate is None:
|
||||
allowed_predicate = media_capture_allowed
|
||||
media_re = _re.compile(r"MEDIA:([^\s\)\]]+)")
|
||||
# #7680 re-gate (9/22): two-pass scan. First strip backtick
|
||||
# wrappers (`` `MEDIA:path` `` → ``MEDIA:path``) so the bare
|
||||
# class below does not consume the closing backtick as part of
|
||||
# the path. Then the bare class (no backtick in the exclusion
|
||||
# set) captures the full filename even when the path itself
|
||||
# contains a backtick (e.g. ``report`final.png``).
|
||||
backtick_re = _re.compile(r"`MEDIA:([^`\s]+)`")
|
||||
captured = 0
|
||||
for msg in messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
@@ -530,6 +537,7 @@ def annotate_media_snapshots(
|
||||
if not text_parts:
|
||||
continue
|
||||
text = "\n".join(text_parts)
|
||||
text = backtick_re.sub(lambda m: f"MEDIA:{m.group(1)}", text)
|
||||
refs = media_re.findall(text)
|
||||
if not refs:
|
||||
continue
|
||||
|
||||
+90
-11
@@ -48,7 +48,11 @@ from api.agent_sessions import (
|
||||
from api.process_event_utils import stamp_message_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CLI_VISIBLE_SESSION_LIMIT = 20
|
||||
# Size of the interactive sidebar recency window. Also bounds how many
|
||||
# delegated subagent children can be rendered at once, since a child only
|
||||
# nests when it wins a slot in this window. Resolved in api.config before
|
||||
# profile init; override with HERMES_WEBUI_VISIBLE_SESSION_LIMIT (clamped 1–200).
|
||||
CLI_VISIBLE_SESSION_LIMIT = _cfg.CLI_VISIBLE_SESSION_LIMIT
|
||||
# How many messageful cron sessions to surface in the project-chip layer.
|
||||
# Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay
|
||||
# addressable even when many newer non-cron sessions dominate the default
|
||||
@@ -4784,7 +4788,41 @@ class _FullSessionResolveRequired(RuntimeError):
|
||||
"""Internal signal that a full sidecar load must enter the bounded path."""
|
||||
|
||||
|
||||
_FULL_SESSION_RESOLVE_MAX_CONCURRENT = 2
|
||||
def _read_max_session_resolve_concurrent() -> int:
|
||||
"""#7421: env override for the heavy session-resolve cap. The
|
||||
hardcoded literal 2 is too low for high-concurrency
|
||||
deployments where several parallel active sessions all need
|
||||
a full-transcript resolve; operators can set
|
||||
``HERMES_WEBUI_MAX_SESSION_RESOLVE`` to a positive int to
|
||||
raise the cap without code changes. A bad or missing value
|
||||
falls back to the previous default of 2, so an operator who
|
||||
sets ``HERMES_WEBUI_MAX_SESSION_RESOLVE=0`` or a non-numeric
|
||||
value does not regress to a blocked or unbounded state.
|
||||
|
||||
The cap is a *safety bound* (per-resolve cost tracked in
|
||||
#7310), not a user preference — a typo like ``=999999``
|
||||
must fall back to the safe default, never to the permissive
|
||||
extreme. Every other bad input in this function falls back
|
||||
to ``2``; out-of-range values do too. The previously
|
||||
accepted upper bound (64) is reachable only via an explicit
|
||||
in-range operator value; a profile's ``.env`` cannot reach
|
||||
it at all (see ``_PROTECTED_ENV_KEYS``).
|
||||
"""
|
||||
raw = (os.getenv("HERMES_WEBUI_MAX_SESSION_RESOLVE") or "").strip()
|
||||
if not raw:
|
||||
return 2
|
||||
try:
|
||||
n = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
if n < 1:
|
||||
return 2
|
||||
if n > 64:
|
||||
return 2 # #7656 round-3: typo-safe fallback, not clamp-up-to-64
|
||||
return n
|
||||
|
||||
|
||||
_FULL_SESSION_RESOLVE_MAX_CONCURRENT = _read_max_session_resolve_concurrent()
|
||||
_FULL_SESSION_RESOLVE_SLOTS = threading.BoundedSemaphore(
|
||||
_FULL_SESSION_RESOLVE_MAX_CONCURRENT
|
||||
)
|
||||
@@ -8581,9 +8619,26 @@ def get_state_db_session_messages(
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {str(row['name']) for row in cur.fetchall()}
|
||||
if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols):
|
||||
# session_source/model_config carry the fork/delegate boundary
|
||||
# identity consumed by _is_continuation_session (#6931/#7021).
|
||||
# Select them when present so the branch-marker guard applies
|
||||
# to the stitch path too; older schemas degrade to NULL.
|
||||
identity_cols = []
|
||||
if 'session_source' in session_cols:
|
||||
identity_cols.append('session_source')
|
||||
else:
|
||||
identity_cols.append('NULL AS session_source')
|
||||
if 'model_config' in session_cols:
|
||||
identity_cols.append('model_config')
|
||||
else:
|
||||
identity_cols.append('NULL AS model_config')
|
||||
lineage_select = (
|
||||
"id, source, started_at, parent_session_id, ended_at, end_reason"
|
||||
+ ", " + ", ".join(identity_cols)
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
|
||||
f"""
|
||||
SELECT {lineage_select}
|
||||
FROM sessions
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -8601,8 +8656,8 @@ def get_state_db_session_messages(
|
||||
if not parent_id or parent_id in seen:
|
||||
break
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
|
||||
f"""
|
||||
SELECT {lineage_select}
|
||||
FROM sessions
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -10382,12 +10437,18 @@ def _insert_state_message_chronologically(messages: list, msg: dict) -> bool:
|
||||
# preceding assistant's tool_calls, not just the first — a multi-tool
|
||||
# turn has several adjacent tool results, and inserting between any of
|
||||
# them splits the block (assistant, tool, <insert>, tool).
|
||||
if idx < len(messages) and messages[idx].get("role") == "tool":
|
||||
# Walk back over any contiguous tool rows already emitted for
|
||||
# this block, so the owning assistant is found even when idx
|
||||
# lands on the SECOND result of a multi-tool turn (where
|
||||
# messages[idx - 1] is another tool row, not the assistant).
|
||||
owner = idx - 1
|
||||
while owner >= 0 and messages[owner].get("role") == "tool":
|
||||
owner -= 1
|
||||
if (
|
||||
idx < len(messages)
|
||||
and messages[idx].get("role") == "tool"
|
||||
and idx > 0
|
||||
and messages[idx - 1].get("role") == "assistant"
|
||||
and messages[idx - 1].get("tool_calls")
|
||||
owner >= 0
|
||||
and messages[owner].get("role") == "assistant"
|
||||
and messages[owner].get("tool_calls")
|
||||
):
|
||||
while idx < len(messages) and messages[idx].get("role") == "tool":
|
||||
idx += 1
|
||||
@@ -10420,6 +10481,7 @@ def merge_session_messages_append_only(
|
||||
*,
|
||||
truncation_watermark=None,
|
||||
truncation_boundary=None,
|
||||
incoming_provenance: Literal["unverified", "state_db"] = "unverified",
|
||||
) -> list:
|
||||
"""Merge sidecar/context and state.db messages without deleting local rows.
|
||||
|
||||
@@ -10433,6 +10495,7 @@ def merge_session_messages_append_only(
|
||||
state_messages,
|
||||
truncation_watermark=truncation_watermark,
|
||||
truncation_boundary=truncation_boundary,
|
||||
incoming_provenance=incoming_provenance,
|
||||
)
|
||||
finally:
|
||||
_STRUCTURED_IDENTITY_MEMO.reset(token)
|
||||
@@ -10444,6 +10507,7 @@ def _merge_session_messages_append_only_impl(
|
||||
*,
|
||||
truncation_watermark=None,
|
||||
truncation_boundary=None,
|
||||
incoming_provenance=None,
|
||||
) -> list:
|
||||
"""Merge sidecar/context and state.db messages without deleting local rows.
|
||||
|
||||
@@ -11038,6 +11102,20 @@ def _merge_session_messages_append_only_impl(
|
||||
seen_dedup_keys.add(dedup_key)
|
||||
seen_content_keys.add(content_key)
|
||||
seen_visible_keys.add(visible_key)
|
||||
# This terminal path is shared by state.db reconciliation and by ordered
|
||||
# sidecar stitching (notably compression continuations). Only the caller
|
||||
# that read state.db may authorize timestamp-based recovery placement;
|
||||
# stable child-sidecar sequence remains authoritative even when an
|
||||
# archived parent was restamped later.
|
||||
if (
|
||||
incoming_provenance == "state_db"
|
||||
and max_sidecar_timestamp is not None
|
||||
and timestamp is not None
|
||||
and timestamp < max_sidecar_timestamp
|
||||
):
|
||||
if not _insert_state_message_chronologically(merged_messages, msg):
|
||||
merged_messages.append(msg)
|
||||
else:
|
||||
merged_messages.append(msg)
|
||||
_remember_merged_message(msg, source="state")
|
||||
return merged_messages
|
||||
@@ -11158,6 +11236,7 @@ def reconciled_state_db_messages_for_session(
|
||||
state_messages,
|
||||
truncation_watermark=getattr(session, "truncation_watermark", None),
|
||||
truncation_boundary=getattr(session, "truncation_boundary", None),
|
||||
incoming_provenance="state_db",
|
||||
)
|
||||
return _state_db_session_messages_result(
|
||||
reconciled_messages,
|
||||
|
||||
+3
-1
@@ -71,7 +71,9 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"default_model": "anthropic/claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": [
|
||||
{"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
|
||||
{"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air"}
|
||||
if model["id"] == "zai/glm-4.5-flash"
|
||||
else {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
|
||||
for model in _FALLBACK_MODELS
|
||||
],
|
||||
"category": "easy_start",
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
@@ -451,15 +452,45 @@ def complete_async_delegation_delivery(
|
||||
_mark_legacy_async_delivery_complete(claim.delegation_id)
|
||||
|
||||
|
||||
def _release_accepts_retryable(release_fn: Any) -> bool:
|
||||
"""Whether the core ``release_event_delivery`` supports ``retryable=``.
|
||||
|
||||
Older Agent cores predate the keyword; calling them with it would raise
|
||||
``TypeError`` and skip the release entirely.
|
||||
"""
|
||||
try:
|
||||
params = inspect.signature(release_fn).parameters
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if "retryable" in params:
|
||||
return params["retryable"].kind in (
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
||||
|
||||
|
||||
def release_async_delegation_delivery(
|
||||
evt: Any,
|
||||
claim: AsyncDelegationDeliveryClaim,
|
||||
*,
|
||||
retryable: bool = False,
|
||||
) -> None:
|
||||
"""Release a failed claim so a later WebUI consumer can retry it."""
|
||||
"""Release a failed claim so a later WebUI consumer can retry it.
|
||||
|
||||
``retryable=True`` tells the core the refusal was transient, so the claimed
|
||||
attempt is refunded instead of counting against the bounded delivery
|
||||
budget (``tools.async_delegation.release_event_delivery(...,
|
||||
retryable=True)``). A core that predates the keyword gets a plain release:
|
||||
the attempt is then consumed as before, never an exception.
|
||||
"""
|
||||
try:
|
||||
if claim.durable:
|
||||
from tools.async_delegation import release_event_delivery
|
||||
|
||||
if retryable and _release_accepts_retryable(release_event_delivery):
|
||||
release_event_delivery(evt, claim.claim_id, retryable=True)
|
||||
else:
|
||||
release_event_delivery(evt, claim.claim_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
||||
+32
-1
@@ -198,7 +198,19 @@ def _unwrap_profile_home_to_base(home: Path) -> Path:
|
||||
# are operator/deployment-level postures, not per-profile toggles. Letting a
|
||||
# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user
|
||||
# escape isolation (#4589).
|
||||
_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'})
|
||||
_PROTECTED_ENV_KEYS = frozenset({
|
||||
'HERMES_WEBUI_ISOLATED_PROFILE',
|
||||
# Server-wide sidebar window; a profile .env must not widen the per-request query.
|
||||
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
|
||||
# #7656 round-3: HERMES_WEBUI_MAX_SESSION_RESOLVE is a process-wide resource
|
||||
# cap (controls the BoundedSemaphore that gates full-transcript resolves).
|
||||
# A profile's .env is per-profile by design; allowing it to set a
|
||||
# server-wide limit (and a first-load-wins asymmetry) would let a
|
||||
# single profile drag the rest into a tighter or looser posture than
|
||||
# the operator intended. Same shape as the isolated-profile key: only
|
||||
# the operator/launcher env at startup can set it.
|
||||
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
|
||||
})
|
||||
|
||||
|
||||
def _isolated_profile_opt_in() -> bool:
|
||||
@@ -934,6 +946,13 @@ _BLOCKED_RUNTIME_ENV_KEYS = {
|
||||
# #4589: operator/deployment isolation posture — never overridable by a
|
||||
# profile's own env on any runtime/gateway-parity path.
|
||||
'HERMES_WEBUI_ISOLATED_PROFILE',
|
||||
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
|
||||
# #7656 round-3: process-wide resolve cap. Filter out a profile's
|
||||
# .env override the same way we filter the isolated-profile key, so
|
||||
# the cap stays at the operator/launcher value across profile
|
||||
# switches rather than re-sizing to whatever the first-loaded
|
||||
# profile env set.
|
||||
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
|
||||
}
|
||||
|
||||
|
||||
@@ -1866,7 +1885,19 @@ def _compute_profile_skills_stats(profile_dir: Path) -> tuple[int, int]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform
|
||||
except ImportError as exc:
|
||||
logger.debug("agent.skill_utils unavailable; reporting skill stats as unknown: %s", exc)
|
||||
# agent source not mounted (two-container Docker,
|
||||
# HERMES_WEBUI_CHAT_BACKEND=gateway): this must never 500 GET
|
||||
# /api/profiles (#7305). Report the skill stats as unknown — a stable
|
||||
# (0, 0) — instead of keeping a partial shadow of agent.skill_utils
|
||||
# here: a local re-implementation cannot preserve the index walk's
|
||||
# exclusions, frontmatter-name identity or platform filtering, so any
|
||||
# count it produced would be inaccurate. The UI omits the skills line
|
||||
# when the total is 0, so the profile picker stays fully usable.
|
||||
return (0, 0)
|
||||
|
||||
seen_names = set()
|
||||
enabled_count = 0
|
||||
|
||||
+125
-31
@@ -2,6 +2,24 @@
|
||||
|
||||
State-extraction prelude to the routes.py split tracked in #1907.
|
||||
Extracts approval state, not handlers, by design.
|
||||
|
||||
Gateway approvals carry two pieces of state with one lifecycle:
|
||||
|
||||
* the **producer** entry in ``tools.approval._gateway_queues`` — the queued
|
||||
approval itself, which owns the lifecycle; and
|
||||
* the **mirror** entry in ``_pending`` — this module's projection of that
|
||||
producer for the UI.
|
||||
|
||||
A producer is consumed only by an explicit settlement operation, which assigns
|
||||
its result and signals its waiter before fallible projection work begins.
|
||||
``retire_gateway_pending_mirror`` cleans up the UI projection; it must not
|
||||
silently delete a producer, because that would strand the agent thread parked
|
||||
on the producer's event. For a provider-qualified response,
|
||||
``resolve_gateway_pending_run`` matches the exact ``(run_id, approval_id)``
|
||||
pair, settles that producer, and preserves same-run siblings and other runs.
|
||||
When a run becomes terminal, ``settle_gateway_pending_run`` fail-closes every
|
||||
producer owned by that run with ``deny`` before retiring its mirrors; producers
|
||||
owned by another run remain live.
|
||||
"""
|
||||
import queue
|
||||
import threading
|
||||
@@ -505,21 +523,26 @@ def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval
|
||||
_gateway_relay_owners.pop(key, None)
|
||||
|
||||
|
||||
def _settle_gateway_entry(entry, choice: str, reason: str | None = None) -> None:
|
||||
"""Complete a removed producer before any fallible projection work."""
|
||||
entry.result = choice
|
||||
if reason:
|
||||
entry.reason = reason
|
||||
entry.event.set()
|
||||
|
||||
|
||||
def retire_gateway_pending_mirror(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> bool:
|
||||
"""Retire one approval, or every mirror for a terminal run."""
|
||||
"""Retire one approval mirror, 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,
|
||||
@@ -542,15 +565,7 @@ def retire_gateway_pending_mirror(
|
||||
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:
|
||||
if not retired:
|
||||
head, total, changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if changed:
|
||||
@@ -558,11 +573,6 @@ def retire_gateway_pending_mirror(
|
||||
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:
|
||||
@@ -573,6 +583,60 @@ def retire_gateway_pending_mirror(
|
||||
return True
|
||||
|
||||
|
||||
def settle_gateway_pending_run(
|
||||
session_key: str,
|
||||
run_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Fail-close every producer and mirror owned by a terminal gateway run."""
|
||||
run_id = str(run_id or "").strip()
|
||||
if not run_id:
|
||||
return 0, None, 0
|
||||
|
||||
targets = []
|
||||
with _lock:
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("run_id") or "").strip() == run_id:
|
||||
targets.append(entry)
|
||||
else:
|
||||
retained_gateway_queue.append(entry)
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
|
||||
# Removal and waiter settlement are one semantic operation. In
|
||||
# particular, reconciliation and SSE notification below may raise.
|
||||
for entry in targets:
|
||||
_settle_gateway_entry(entry, "deny", reason)
|
||||
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
retained_pending = [
|
||||
entry
|
||||
for entry in entries
|
||||
if not (
|
||||
_is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip() == run_id
|
||||
)
|
||||
]
|
||||
mirrors_retired = len(retained_pending) != len(entries)
|
||||
if retained_pending:
|
||||
_pending[session_key] = retained_pending
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
|
||||
head, total, changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if targets or mirrors_retired or changed:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return len(targets), head, total
|
||||
|
||||
|
||||
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()
|
||||
@@ -804,14 +868,50 @@ def resolve_gateway_pending_local(
|
||||
_gateway_queues[session_key] = gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
if target is not None:
|
||||
_settle_gateway_entry(target, choice, reason)
|
||||
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
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return 1, head, total
|
||||
|
||||
|
||||
def resolve_gateway_pending_run(
|
||||
session_key: str,
|
||||
approval_id: str,
|
||||
run_id: str,
|
||||
choice: str,
|
||||
reason: str | None = None,
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Resolve one exact run-backed producer and wake its parked waiter."""
|
||||
target = None
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
if not approval_id or not run_id:
|
||||
return 0, None, 0
|
||||
with _lock:
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
entry_approval_id = str(data.get("approval_id") or "").strip()
|
||||
entry_run_id = str(data.get("run_id") or "").strip()
|
||||
if target is None and entry_approval_id == approval_id and entry_run_id == run_id:
|
||||
target = entry
|
||||
else:
|
||||
retained_gateway_queue.append(entry)
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
if target is not None:
|
||||
_settle_gateway_entry(target, choice, reason)
|
||||
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
|
||||
|
||||
@@ -856,12 +956,9 @@ def resolve_gateway_pending_local_no_run_mirror(
|
||||
_pending[session_key] = entries
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
_settle_gateway_entry(target, choice, reason)
|
||||
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
|
||||
|
||||
@@ -904,14 +1001,11 @@ def resolve_gateway_pending_local_all(
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
|
||||
for entry in targets:
|
||||
_settle_gateway_entry(entry, choice, reason)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
|
||||
for entry in targets:
|
||||
entry.result = choice
|
||||
if reason:
|
||||
entry.reason = reason
|
||||
entry.event.set()
|
||||
if targets or removed_pending:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return len(targets), head, total
|
||||
|
||||
+461
-49
@@ -46,6 +46,7 @@ from api.agent_sessions import (
|
||||
_looks_like_default_cli_title,
|
||||
is_cli_session_row,
|
||||
is_cli_session_row_visible,
|
||||
open_state_db_readonly,
|
||||
read_session_lineage_report,
|
||||
)
|
||||
from api.compression_anchor import visible_messages_for_anchor
|
||||
@@ -320,7 +321,7 @@ def _latest_cron_session_info_for_jobs(
|
||||
if not db_path or not Path(db_path).exists():
|
||||
return {jid: {"session_id": "", "message_count": None} for jid in requested}
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
with closing(open_state_db_readonly(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
@@ -593,7 +594,15 @@ def _request_session_visibility_exempt(method: str, path: str | None) -> bool:
|
||||
|
||||
|
||||
def _session_id_visible_to_request_profile(handler, sid, *, emit_error: bool = True) -> bool:
|
||||
"""Return whether ``sid`` belongs to the active profile."""
|
||||
"""Return whether ``sid`` belongs to the active profile.
|
||||
|
||||
On a profile mismatch, the helper mirrors the detail-load endpoint's
|
||||
contract (#13043, #13493): return ``409 session_profile_mismatch`` for
|
||||
a session owned by a KNOWN other profile, and keep ``404 Session
|
||||
not found`` only for the unknown/legacy None-profile case so the
|
||||
frontend's self-heal (clear stale URL + localStorage) keeps firing
|
||||
for actually-missing sids. ``#7710``.
|
||||
"""
|
||||
if not isinstance(sid, str) or not sid:
|
||||
return True
|
||||
if not is_safe_session_id(sid):
|
||||
@@ -602,8 +611,22 @@ def _session_id_visible_to_request_profile(handler, sid, *, emit_error: bool = T
|
||||
session = get_session(sid, metadata_only=True)
|
||||
except KeyError:
|
||||
return True
|
||||
if not _session_visible_to_active_profile(getattr(session, "profile", None), handler):
|
||||
session_profile = getattr(session, "profile", None) or None
|
||||
if not _session_visible_to_active_profile(session_profile, handler):
|
||||
if emit_error:
|
||||
if session_profile:
|
||||
j(handler, {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": sid,
|
||||
"profile": session_profile,
|
||||
}, status=409)
|
||||
else:
|
||||
# Unknown/legacy None-profile sidecar: keep the 404 so the
|
||||
# frontend's self-heal still fires. _profiles_match coerces
|
||||
# None->'default', so a truly missing/legacy session under a
|
||||
# non-default active profile would otherwise emit a useless
|
||||
# 409 with profile=null.
|
||||
bad(handler, "Session not found", 404)
|
||||
return False
|
||||
return True
|
||||
@@ -2521,8 +2544,8 @@ def _build_session_list_cache_payload(
|
||||
)
|
||||
if show_cli_sessions:
|
||||
diag_stage("cli_cap")
|
||||
archived_scoped = _cap_recent_cli_sessions(archived_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
|
||||
visible_scoped = _cap_recent_cli_sessions(visible_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
|
||||
archived_scoped = _cap_recent_cli_sessions(archived_scoped)
|
||||
visible_scoped = _cap_recent_cli_sessions(visible_scoped)
|
||||
if visible_only:
|
||||
archived_scoped = [
|
||||
s for s in archived_scoped if _session_has_server_visible_messages(s)
|
||||
@@ -2952,6 +2975,7 @@ from api.helpers import (
|
||||
require,
|
||||
bad,
|
||||
safe_resolve,
|
||||
arm_connection_close_if_body_pending,
|
||||
j,
|
||||
t,
|
||||
read_body,
|
||||
@@ -3461,16 +3485,31 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
emit_error=False,
|
||||
):
|
||||
return None
|
||||
summary = find_run_summary(stream_id)
|
||||
if not summary:
|
||||
# Locate the run journal WITHOUT parsing it first. find_run_summary reads
|
||||
# and parses the whole file (tens of thousands of rows on a long live
|
||||
# run), and read_run_events below then parsed it AGAIN — two full passes
|
||||
# cost ~0.6s per rebuild. Derive the durable summary from the single
|
||||
# parse below instead; its last_seq / last_event_id are rebuilt from the
|
||||
# same rows the snapshot consumes.
|
||||
located = find_run_file(stream_id)
|
||||
if located:
|
||||
session_id, _journal_path = located
|
||||
else:
|
||||
# No journal file on disk for this run id: fall back to the
|
||||
# historical summary-based lookup seam (defensive — a real miss
|
||||
# returns None exactly like before; this is also the seam that
|
||||
# long-standing tests stub with a handler-side summary).
|
||||
fallback_summary = find_run_summary(stream_id)
|
||||
if not fallback_summary:
|
||||
return None
|
||||
session_id = str(summary.get("session_id") or "")
|
||||
session_id = str(fallback_summary.get("session_id") or "")
|
||||
if not session_id:
|
||||
return None
|
||||
journal = read_run_events(session_id, stream_id)
|
||||
events = [event for event in (journal.get("events") or []) if isinstance(event, dict)]
|
||||
if not events:
|
||||
return None
|
||||
summary = _summary_from_events(session_id, stream_id, events)
|
||||
event_run_ids: set[str] = set()
|
||||
malformed_envelope_run_id = False
|
||||
for event in events:
|
||||
@@ -3490,6 +3529,18 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
|
||||
assistant_text = ""
|
||||
reasoning_text = ""
|
||||
# Reasoning deltas arrive as thousands of tiny append events. Growing the
|
||||
# transcript with ``reasoning_text += chunk`` copies the full (multi-
|
||||
# hundred-KB) string on every append inside these closures — quadratic
|
||||
# (~2.3s of a 4.6s rebuild on a 9.7k-chunk run). Collect chunks and
|
||||
# materialize lazily; readers (echo probes, strip, final message) join at
|
||||
# most once per interim segment.
|
||||
reasoning_parts: list[str] = []
|
||||
reasoning_dirty = False
|
||||
# Incremental folded index over the reasoning transcript: the per-interim
|
||||
# echo probe consults this instead of re-walking the raw text (see
|
||||
# ``_CompactEchoIndex``).
|
||||
reasoning_index = _CompactEchoIndex()
|
||||
messages: list[dict] = []
|
||||
tool_calls: list[dict] = []
|
||||
activity_burst_anchors: list[dict] = []
|
||||
@@ -3498,6 +3549,13 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
last_ts = None
|
||||
reasoning_first_tool_count: int | None = None
|
||||
|
||||
def _materialize_reasoning_text() -> str:
|
||||
nonlocal reasoning_text, reasoning_dirty
|
||||
if reasoning_dirty:
|
||||
reasoning_text = "".join(reasoning_parts)
|
||||
reasoning_dirty = False
|
||||
return reasoning_text
|
||||
|
||||
def mark_boundary() -> int:
|
||||
nonlocal current_activity_burst_id
|
||||
text_end = len(assistant_text)
|
||||
@@ -3562,22 +3620,42 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
tool_calls.append(call)
|
||||
|
||||
def reasoning_echo_tail_matches(text: str) -> bool:
|
||||
candidate = _compact_for_echo_compare(text)
|
||||
if not candidate:
|
||||
return False
|
||||
return _compact_for_echo_compare(reasoning_text).endswith(candidate)
|
||||
# Indexed tail match (no raw-text walk): the incremental index folds
|
||||
# each reasoning chunk once as it is appended, so this probe costs
|
||||
# O(len(text)) regardless of how much interior whitespace stretches
|
||||
# the raw span. The previous raw backward walk re-walked that span
|
||||
# once per interim event, which turned the replay quadratic on a
|
||||
# whitespace-heavy transcript (#7569 review, ~55x slower than master
|
||||
# on a production-shaped journal).
|
||||
return reasoning_index.matches_tail(text)
|
||||
|
||||
def strip_reasoning_echo_tail(text: str) -> bool:
|
||||
nonlocal reasoning_text, reasoning_first_tool_count
|
||||
next_reasoning, did_remove = _strip_compact_echo_suffix(reasoning_text, text)
|
||||
if did_remove:
|
||||
nonlocal reasoning_text, reasoning_dirty, reasoning_first_tool_count
|
||||
cut = reasoning_index.cut_to(text)
|
||||
if cut is None:
|
||||
return False
|
||||
next_reasoning = _materialize_reasoning_text()[:cut].rstrip()
|
||||
reasoning_text = next_reasoning
|
||||
reasoning_parts.clear()
|
||||
reasoning_parts.append(next_reasoning)
|
||||
reasoning_dirty = False
|
||||
# Re-index the truncated transcript so later probes match against it
|
||||
# instead of the pre-strip tail (the index is the match authority).
|
||||
reasoning_index.reset()
|
||||
reasoning_index.append(next_reasoning)
|
||||
if not _compact_for_echo_compare(reasoning_text):
|
||||
reasoning_first_tool_count = None
|
||||
return did_remove
|
||||
return True
|
||||
|
||||
for event in events:
|
||||
event_name = str(event.get("event") or event.get("type") or "")
|
||||
if event_name == "metering":
|
||||
# Metering rows are the bulk of a long live journal (~10k rows,
|
||||
# ~60% of its bytes) and project nothing onto the snapshot; skip
|
||||
# their per-row work while preserving the last_ts watermark they
|
||||
# carry.
|
||||
last_ts = event.get("created_at", last_ts)
|
||||
continue
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
last_ts = event.get("created_at", last_ts)
|
||||
if event_name == "token":
|
||||
@@ -3588,9 +3666,12 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
continue
|
||||
if event_name == "reasoning":
|
||||
text = str(payload.get("text") or "")
|
||||
if text and reasoning_first_tool_count is None:
|
||||
if text:
|
||||
if reasoning_first_tool_count is None:
|
||||
reasoning_first_tool_count = len(tool_calls)
|
||||
reasoning_text += text
|
||||
reasoning_parts.append(text)
|
||||
reasoning_index.append(text)
|
||||
reasoning_dirty = True
|
||||
continue
|
||||
if event_name == "interim_assistant":
|
||||
visible = str(payload.get("text") or "").strip()
|
||||
@@ -3635,6 +3716,8 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
|
||||
update_completed_tool(payload)
|
||||
fresh_segment = True
|
||||
|
||||
reasoning_text = _materialize_reasoning_text()
|
||||
|
||||
if assistant_text or reasoning_text:
|
||||
message = {
|
||||
"role": "assistant",
|
||||
@@ -5323,7 +5406,21 @@ def _handle_session_anchor_scene(handler, body):
|
||||
# this an authenticated request under profile A could persist anchor scenes
|
||||
# onto a session owned by profile B (cross-profile write). Reject as 404 —
|
||||
# same shape the read path uses — and leave anchor_activity_scenes untouched.
|
||||
if not _session_visible_to_active_profile(getattr(s, "profile", None) or None, handler):
|
||||
# #7710: cross-profile writes are rejected with 409
|
||||
# ``session_profile_mismatch`` so the client can offer to switch
|
||||
# to the owning profile (mirrors the detail-load endpoint's
|
||||
# contract at #13043 / #13493). 404 is preserved for the
|
||||
# None-profile (unknown/legacy) case so the frontend self-heal
|
||||
# path still fires for actually-missing sids.
|
||||
_anchor_session_profile = getattr(s, "profile", None) or None
|
||||
if not _session_visible_to_active_profile(_anchor_session_profile, handler):
|
||||
if _anchor_session_profile:
|
||||
return j(handler, {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": sid,
|
||||
"profile": _anchor_session_profile,
|
||||
}, status=409)
|
||||
return bad(handler, "Session not found", 404)
|
||||
with _get_session_agent_lock(sid):
|
||||
idx, message = _find_anchor_scene_message(
|
||||
@@ -5835,6 +5932,15 @@ def _csrf_rejection_error(handler) -> str:
|
||||
def _check_csrf(handler) -> bool:
|
||||
"""Reject cross-origin or tokenless authenticated browser unsafe requests."""
|
||||
if not _check_same_origin_browser_request(handler):
|
||||
# CSRF checks run before read_body(), so close rather than reusing an
|
||||
# HTTP/1.1 connection whose unread body would corrupt the next request --
|
||||
# but ONLY when the framing says bytes are really queued. Arming
|
||||
# unconditionally dropped a healthy pooled connection on a body-less
|
||||
# write: verified on the wire, `POST /api/session/new` with
|
||||
# `Origin: http://evil.invalid` and no `Content-Length` (and with
|
||||
# `Content-Length: 0`) answered 403 + `Connection: close` and the
|
||||
# pipelined `GET /api/health/agent` was never served.
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
return False
|
||||
if not _is_browser_unsafe_request(handler):
|
||||
return True # non-browser clients (curl, MCP, agent) have no Origin/Referer
|
||||
@@ -5847,6 +5953,11 @@ def _check_csrf(handler) -> bool:
|
||||
submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token")
|
||||
if verify_csrf_token(cookie_val or "", submitted or ""):
|
||||
return True
|
||||
# Same framing rule as the origin rejection above: a token mismatch on a
|
||||
# body-less write has nothing unread to protect. Verified on the wire with an
|
||||
# authenticated same-origin `POST /api/session/new` carrying no CSRF token and
|
||||
# no `Content-Length`: 403 + `Connection: close`, follow-up dropped.
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
return _set_csrf_failure_reason(handler, "token_mismatch")
|
||||
|
||||
|
||||
@@ -6026,6 +6137,14 @@ def _handle_extension_sidecar_proxy(
|
||||
# DELETE fell through the CSRF compatibility path that intentionally admits
|
||||
# non-browser clients, giving unsafe methods weaker provenance than GET.
|
||||
if not _check_same_origin_browser_request(handler, require_provenance=True):
|
||||
# Provenance rejection runs before read_body(), so close-and-advertise
|
||||
# whenever the request DECLARED a body (Content-Length non-zero, or any
|
||||
# Transfer-Encoding): those bytes are still queued in rfile and a reused
|
||||
# HTTP/1.1 connection would parse them as the next request line (same
|
||||
# class as _check_csrf). Gating on read_request_body instead was wrong in
|
||||
# both directions — it missed a GET that carries a declared body, and it
|
||||
# closed a healthy connection on a body-less DELETE/PUT/PATCH.
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
|
||||
try:
|
||||
request_body = _read_body_bytes(handler) if read_request_body else None
|
||||
@@ -6468,6 +6587,13 @@ def _handle_csp_report(handler) -> bool:
|
||||
"Dropped CSP report from %s: rate limit exceeded",
|
||||
_client_ip_for_rate_limit(handler),
|
||||
)
|
||||
# Rate-limit rejection runs before the body is read; close-and-advertise
|
||||
# so the unread report can't corrupt the next pooled request -- but only
|
||||
# when a body was really declared. A body-less report answered 204 WITH
|
||||
# `Connection: close` once the 100-per-60s limiter tripped (reproduced on
|
||||
# the wire at request 101; the pipelined `GET /api/health/agent` was
|
||||
# dropped), so a browser that keeps reporting loses its socket each time.
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
return _send_no_content(handler)
|
||||
|
||||
payload = _read_csp_report_payload(handler)
|
||||
@@ -9230,6 +9356,7 @@ def _limited_webui_messages_for_display_with_sidecar(
|
||||
state_db_messages,
|
||||
truncation_watermark=getattr(session, "truncation_watermark", None),
|
||||
truncation_boundary=getattr(session, "truncation_boundary", None),
|
||||
incoming_provenance="state_db",
|
||||
)
|
||||
if cache_key is not None:
|
||||
_state_key = cache_key[4]
|
||||
@@ -10278,11 +10405,16 @@ def _dedupe_cli_sidebar_sessions_for_api(
|
||||
return _include_project_hidden_background_sidebar_sessions(candidates, visible)
|
||||
|
||||
|
||||
CLI_VISIBLE_SESSION_CAP = 20
|
||||
def _cli_visible_session_cap() -> int:
|
||||
"""Shared sidebar window, not a second hard-coded 20."""
|
||||
from api.config import CLI_VISIBLE_SESSION_LIMIT
|
||||
return CLI_VISIBLE_SESSION_LIMIT
|
||||
|
||||
|
||||
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]:
|
||||
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int | None = None) -> list[dict]:
|
||||
"""Keep only the most recent CLI-visible sessions after filtering."""
|
||||
if cli_cap is None:
|
||||
cli_cap = _cli_visible_session_cap()
|
||||
if cli_cap <= 0:
|
||||
return sessions
|
||||
kept = []
|
||||
@@ -10488,6 +10620,11 @@ def _pre_compression_continuation_session_id(session) -> str | None:
|
||||
exists either in memory or on disk. Follow bounded snapshot-to-snapshot hops
|
||||
so repeated compression still lands on the latest visible continuation.
|
||||
"""
|
||||
from api.compression_continuation import durable_compression_continuation
|
||||
|
||||
sealed, tip = durable_compression_continuation(session)
|
||||
if sealed:
|
||||
return tip
|
||||
if not getattr(session, "pre_compression_snapshot", False):
|
||||
return None
|
||||
sid = _safe_first(getattr(session, "session_id", None))
|
||||
@@ -10698,13 +10835,16 @@ from api.streaming import (
|
||||
_materialize_pending_user_turn_before_error,
|
||||
generate_session_title_for_session,
|
||||
_compact_for_echo_compare,
|
||||
_strip_compact_echo_suffix,
|
||||
_CompactEchoIndex,
|
||||
)
|
||||
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
|
||||
from api.run_journal import (
|
||||
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
|
||||
_summary_from_events,
|
||||
bound_run_journal_snapshot_args,
|
||||
find_run_file,
|
||||
find_run_summary,
|
||||
journal_replay_visible,
|
||||
read_run_events,
|
||||
read_session_run_events,
|
||||
session_journal_fingerprint,
|
||||
@@ -10765,8 +10905,10 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp
|
||||
gateway_pending_mirrors,
|
||||
release_gateway_approval_relay_owner,
|
||||
retire_gateway_pending_mirror,
|
||||
settle_gateway_pending_run,
|
||||
reconcile_gateway_pending_mirror_locked,
|
||||
resolve_gateway_pending_local,
|
||||
resolve_gateway_pending_run,
|
||||
resolve_gateway_pending_local_all,
|
||||
resolve_gateway_pending_local_no_run_mirror,
|
||||
set_session_yolo_enabled,
|
||||
@@ -11080,9 +11222,7 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
<h1>{{BOT_NAME}}</h1>
|
||||
<p class="sub">{{LOGIN_SUBTITLE}}</p>
|
||||
<form id="login-form" data-invalid-pw="{{LOGIN_INVALID_PW}}" data-conn-failed="{{LOGIN_CONN_FAILED}}">
|
||||
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus>
|
||||
<button type="submit">{{LOGIN_BTN}}</button>
|
||||
<button type="button" id="passkey-login" class="passkey-login" style="display:none">Sign in with passkey</button>
|
||||
{{PASSWORD_FORM_HTML}}
|
||||
{{OIDC_LOGIN_HTML}}
|
||||
</form>
|
||||
<div class="err" id="err"></div>
|
||||
@@ -11897,7 +12037,7 @@ def _handle_insights(handler, parsed) -> bool:
|
||||
from api.models import _active_state_db_path
|
||||
db_path = _active_state_db_path()
|
||||
if db_path and db_path.exists():
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
with closing(open_state_db_readonly(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
# cache_read_tokens may not exist on older agent state DBs;
|
||||
@@ -12532,7 +12672,7 @@ def _deep_health_checks(stream_check: dict | None = None) -> tuple[dict, bool]:
|
||||
"ms": round((time.time() - t0) * 1000, 1),
|
||||
}
|
||||
else:
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
with closing(open_state_db_readonly(db_path)) as conn:
|
||||
conn.execute("PRAGMA schema_version").fetchone()
|
||||
checks["state_db"] = {
|
||||
"status": "ok",
|
||||
@@ -12853,6 +12993,15 @@ def _handle_shutdown(handler) -> bool:
|
||||
|
||||
def _handle_health_restart(handler) -> bool:
|
||||
"""Restart the Hermes messaging gateway service."""
|
||||
# This endpoint never consumes its request body on any outcome, so close when
|
||||
# one was DECLARED -- and only then. Arming unconditionally closed the socket
|
||||
# on every call including the successful, body-less one the WebUI actually
|
||||
# makes: verified on the wire, `POST /api/health/restart` with no
|
||||
# `Content-Length` answered with `Connection: close` and the pipelined
|
||||
# `GET /api/health/agent` was never served. The single arming covers every
|
||||
# outcome below (completed / in_progress / busy / error) because the framing,
|
||||
# not the result, decides.
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
outcome = restart_active_profile_gateway()
|
||||
|
||||
if outcome.get("status") == "completed":
|
||||
@@ -13625,6 +13774,36 @@ def handle_get(handler, parsed) -> bool:
|
||||
]
|
||||
from urllib.parse import quote
|
||||
from api.updates import WEBUI_VERSION
|
||||
# #7056: only render the password input / submit / passkey controls
|
||||
# when password auth is actually enabled. With native OIDC configured
|
||||
# and ``HERMES_WEBUI_PASSWORD`` unset, the form previously still
|
||||
# displayed the password prompt and accepted — silently 401-ing at
|
||||
# the server — every submit. The OIDC SSO entry point stays the
|
||||
# sole path. ``is_password_auth_enabled`` is the same predicate
|
||||
# ``/api/auth/status`` reports as ``password_auth_enabled``.
|
||||
from api.auth import are_passkeys_enabled, is_password_auth_enabled
|
||||
|
||||
# The password INPUT is gated on a configured password, but the passkey
|
||||
# button must survive a passwordless-passkey deployment: settings expose
|
||||
# ``passwordless_enabled = passkeys registered AND not password_auth_enabled``
|
||||
# (routes.py ~14059) and ``is_auth_enabled()`` counts passkeys as an
|
||||
# independent auth method, so hiding the button when no password is set
|
||||
# would remove the ONLY working login affordance for those instances.
|
||||
_passkey_button_html = (
|
||||
'<button type="button" id="passkey-login" class="passkey-login" '
|
||||
'style="display:none">Sign in with passkey</button>'
|
||||
)
|
||||
if is_password_auth_enabled():
|
||||
_password_form_html = (
|
||||
f'<input type="password" id="pw" '
|
||||
f'placeholder="{_html.escape(_login_strings["placeholder"])}" autofocus>'
|
||||
f'<button type="submit">{_html.escape(_login_strings["btn"])}</button>'
|
||||
f'{_passkey_button_html}'
|
||||
)
|
||||
elif are_passkeys_enabled():
|
||||
_password_form_html = _passkey_button_html
|
||||
else:
|
||||
_password_form_html = ""
|
||||
version_token = quote(WEBUI_VERSION, safe="")
|
||||
_page = (
|
||||
_LOGIN_PAGE_HTML.replace("{{BOT_NAME}}", _bn)
|
||||
@@ -13633,10 +13812,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
.replace("{{LANG}}", _html.escape(_login_strings["lang"]))
|
||||
.replace("{{LOGIN_TITLE}}", _html.escape(_login_strings["title"]))
|
||||
.replace("{{LOGIN_SUBTITLE}}", _html.escape(_login_strings["subtitle"]))
|
||||
.replace(
|
||||
"{{LOGIN_PLACEHOLDER}}", _html.escape(_login_strings["placeholder"])
|
||||
)
|
||||
.replace("{{LOGIN_BTN}}", _html.escape(_login_strings["btn"]))
|
||||
.replace("{{PASSWORD_FORM_HTML}}", _password_form_html)
|
||||
.replace("{{LOGIN_INVALID_PW}}", _html.escape(_login_strings["invalid_pw"]))
|
||||
.replace(
|
||||
"{{LOGIN_CONN_FAILED}}", _html.escape(_login_strings["conn_failed"])
|
||||
@@ -14478,7 +14654,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
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)
|
||||
settle_gateway_pending_run(
|
||||
owner_sid,
|
||||
run_id,
|
||||
reason="Gateway run was cancelled before approval resolution",
|
||||
)
|
||||
else:
|
||||
gateway_stop_blocked = True
|
||||
except Exception:
|
||||
@@ -15144,6 +15324,12 @@ def handle_post(handler, parsed) -> bool:
|
||||
if diag:
|
||||
diag.stage("process_complete_ack_deprecated")
|
||||
try:
|
||||
# The 410 runs before the stale tab's JSON body is read;
|
||||
# close-and-advertise so those unread bytes can't corrupt the next
|
||||
# pooled request -- but a body-less ack has none, and closing then
|
||||
# just kills keep-alive (on the wire: 410 + `Connection: close` with
|
||||
# no `Content-Length`, pipelined follow-up dropped).
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
j(
|
||||
handler,
|
||||
{
|
||||
@@ -18573,6 +18759,8 @@ def _replay_run_journal(
|
||||
max_seq=max_seq,
|
||||
)
|
||||
for entry in journal.get("events") or []:
|
||||
if not journal_replay_visible(entry):
|
||||
continue
|
||||
_sse_with_id(
|
||||
handler,
|
||||
entry.get("event") or entry.get("type") or "message",
|
||||
@@ -19026,14 +19214,14 @@ def _handle_sse_stream(handler, parsed):
|
||||
continue
|
||||
if len(item) >= 3:
|
||||
event, data, queued_event_id = item[0], item[1], item[2]
|
||||
event_id = queued_event_id
|
||||
else:
|
||||
event, data = item
|
||||
queued_event_id = STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
event_id = STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
# Stage-364: emit `id:` from STREAM_LAST_EVENT_ID side-channel so
|
||||
# the frontend's `_lastRunJournalSeq` cursor advances during live
|
||||
# streaming. Without this, mid-stream error→replay would arrive
|
||||
# with after_seq=0 and double-render every journaled event.
|
||||
event_id = queued_event_id or STREAM_LAST_EVENT_ID.get(stream_id)
|
||||
event_seq = _run_journal_same_run_seq(event_id, stream_id)
|
||||
if replay_cutoff_seq is not None and event_seq is not None and event_seq <= replay_cutoff_seq:
|
||||
continue
|
||||
@@ -19108,6 +19296,8 @@ def _handle_session_run_journal_stream_for_session(handler, parsed, session_id):
|
||||
|
||||
def emit_replay(events, stream_id, cutoff_seq):
|
||||
for entry in events:
|
||||
if not journal_replay_visible(entry):
|
||||
continue
|
||||
event_id = str(entry.get("event_id") or "")
|
||||
event_seq = _run_journal_same_run_seq(event_id, stream_id)
|
||||
if cutoff_seq is not None and event_seq is not None and event_seq > cutoff_seq:
|
||||
@@ -19174,10 +19364,10 @@ def _handle_session_run_journal_stream_for_session(handler, parsed, session_id):
|
||||
continue
|
||||
if len(item) >= 3:
|
||||
event, data, queued_event_id = item[0], item[1], item[2]
|
||||
event_id = queued_event_id
|
||||
else:
|
||||
event, data = item
|
||||
queued_event_id = STREAM_LAST_EVENT_ID.get(active_stream_id)
|
||||
event_id = queued_event_id or STREAM_LAST_EVENT_ID.get(active_stream_id)
|
||||
event_id = STREAM_LAST_EVENT_ID.get(active_stream_id)
|
||||
event_seq = _run_journal_same_run_seq(event_id, active_stream_id)
|
||||
_is_terminal = event in SSE_RELAY_CLOSE_EVENTS
|
||||
_already_sent = (
|
||||
@@ -20444,6 +20634,16 @@ def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp
|
||||
|
||||
|
||||
_MEDIA_TOKEN_RE = re.compile(r"MEDIA:([^\s\)\]]+)")
|
||||
# #7680 re-gate (9/22): two-pass scan.
|
||||
# 1. `` `MEDIA:path` `` (backtick-wrapped, inline-code form) → strip
|
||||
# the wrapping backticks so the bare-token pass below sees a
|
||||
# plain ``MEDIA:path`` and the closing backtick is not consumed
|
||||
# as part of the path.
|
||||
# 2. ``MEDIA:[^\s\)\]]+`` (bare, no backtick in the exclusion
|
||||
# class) so a filename that legally contains a backtick
|
||||
# (``report`final.png``) is captured in full instead of being
|
||||
# truncated at the first backtick.
|
||||
_BACKTICK_MEDIA_RE = re.compile(r"`MEDIA:([^`\s]+)`")
|
||||
|
||||
|
||||
def _message_content_text(content) -> str:
|
||||
@@ -20503,6 +20703,10 @@ def _session_media_token_allows_path(sid: str, target: Path, allowed_mimes: set[
|
||||
)
|
||||
if "MEDIA:" not in text:
|
||||
continue
|
||||
# #7680 re-gate: strip backtick wrappers first so the bare
|
||||
# class below captures the full path even when the filename
|
||||
# itself contains a backtick.
|
||||
text = _BACKTICK_MEDIA_RE.sub(lambda m: f"MEDIA:{m.group(1)}", text)
|
||||
for ref in _MEDIA_TOKEN_RE.findall(text):
|
||||
if "://" in ref:
|
||||
continue
|
||||
@@ -21695,6 +21899,99 @@ def _handle_live_models(handler, parsed):
|
||||
_env = str(_cp.get("key_env") or "").strip()
|
||||
return os.getenv(_env, "").strip() if _env else ""
|
||||
|
||||
def _custom_provider_models_discover_is_false(_cp):
|
||||
"""True when ``discover_models`` is an explicit ``false`` opt-out.
|
||||
|
||||
Mirrors ``api.config._provider_discover_allowed`` / Hermes
|
||||
Agent ``model_switch_providers._discover_flag``: ``discover_models``
|
||||
defaults to True, and the string forms ``"false"``/``"no"``/``"0"``
|
||||
(case-insensitive) mean False. Used to decide whether a
|
||||
dict-shaped ``models`` mapping is a hand-pinned allowlist (only
|
||||
when discovery is off) rather than per-model metadata.
|
||||
"""
|
||||
_discover = _cp.get("discover_models", True) if isinstance(_cp, dict) else True
|
||||
if isinstance(_discover, str):
|
||||
return _discover.strip().lower() in {"false", "no", "0"}
|
||||
return not bool(_discover)
|
||||
|
||||
def _custom_provider_allowlist_ids(_cp):
|
||||
"""Plural ``models`` list only — the explicit allowlist signal.
|
||||
|
||||
The singular ``model`` field is sticky/default metadata, NOT an
|
||||
allowlist: it must not gate live-catalog filtering, otherwise a
|
||||
provider configured with only ``model: assistant`` (no ``models``
|
||||
list) would be collapsed to a single model. Only an explicit
|
||||
``models`` allowlist expresses "show exactly these models".
|
||||
|
||||
An auto-discovered catalog is NOT an allowlist: when Hermes
|
||||
persisted discovery results back into config (``models: {...}``
|
||||
plus ``models_discovered: true``), that mapping is a snapshot
|
||||
of what the gateway exposed at discovery time. Gating on it
|
||||
would permanently pin the live catalog to the first-discovery
|
||||
set, silently dropping any model the user pulls in later
|
||||
(LM Studio / Ollama). ``_provider_models_are_discovered_catalog()``
|
||||
is the shared predicate the ``/api/models`` path already uses;
|
||||
when it says "discovered", return no allowlist and let the
|
||||
live probe win. An explicit ``discover_models: false`` opt-out
|
||||
re-pins the catalog (the predicate accounts for it), so a
|
||||
hand-pinned discovered catalog still filters.
|
||||
|
||||
A dict-shaped ``models`` mapping that is NOT marked discovered is
|
||||
*per-model metadata* written by the Hermes Agent setup flow
|
||||
(``hermes_cli/model_switch.py::_save_custom_provider`` and the
|
||||
setup wizard) — e.g. ``{chat-a: {context_length: 128000}}``. It is
|
||||
not a catalog narrow: treating its keys as an allowlist would
|
||||
collapse the live picker to the single saved default (keyless
|
||||
Ollama) while the CLI live-probe shows the full catalog. Only an
|
||||
explicit ``discover_models: false`` opts into treating the dict
|
||||
keys as a pinned allowlist. List/JSON-array-string/Python-literal
|
||||
shapes remain plain allowlists.
|
||||
|
||||
The plural value is decoded through ``_parse_config_string_list()``
|
||||
because ``hermes config set`` / JSON-mode editor saves persist
|
||||
lists as quoted JSON-array strings (``'["chat-a","chat-b"]'``) or
|
||||
Python literals (``"['chat-a']"``). Handling only ``dict``/``list``
|
||||
made a serialized allowlist fall through to ``[]``, which the
|
||||
caller reads as "no allowlist configured" and floods the picker
|
||||
with the full upstream catalog — the same class of bug as the
|
||||
``skills.disabled`` regression (#7120 / #7134).
|
||||
"""
|
||||
from api.config import _provider_models_are_discovered_catalog
|
||||
|
||||
if _provider_models_are_discovered_catalog(_cp):
|
||||
return []
|
||||
_ids = []
|
||||
_models = _cp.get("models")
|
||||
# Serialized shapes only: ``hermes config set`` / JSON-mode
|
||||
# editor saves persist lists as quoted JSON-array strings
|
||||
# (``'["chat-a","chat-b"]'``) or Python literals
|
||||
# (``"['chat-a']"``). Decode those through the shared helper so
|
||||
# they are honored; native dict/list values are walked below so
|
||||
# dict *entries* keep their id|model|name metadata (the decoder
|
||||
# str()-ifies list members, which would mangle them).
|
||||
if isinstance(_models, str):
|
||||
_models = _parse_config_string_list(_models)
|
||||
if isinstance(_models, dict):
|
||||
# A dict-shaped ``models`` is per-model metadata written by
|
||||
# the Hermes Agent setup flow, NOT a catalog narrow. Only a
|
||||
# ``discover_models: false`` opt-out treats the dict keys as a
|
||||
# pinned allowlist; otherwise return the empty allowlist so the
|
||||
# live probe returns the full catalog.
|
||||
if not _custom_provider_models_discover_is_false(_cp):
|
||||
return []
|
||||
for _mid in _models:
|
||||
if isinstance(_mid, str) and _mid.strip():
|
||||
_ids.append(_mid.strip())
|
||||
elif isinstance(_models, (list, tuple)):
|
||||
for _item in _models:
|
||||
if isinstance(_item, str) and _item.strip():
|
||||
_ids.append(_item.strip())
|
||||
elif isinstance(_item, dict):
|
||||
_mid = _item.get("id") or _item.get("model") or _item.get("name")
|
||||
if _mid and str(_mid).strip():
|
||||
_ids.append(str(_mid).strip())
|
||||
return _ids
|
||||
|
||||
# For 'custom' and 'custom:*' providers, provider_model_ids()
|
||||
# returns [] because they aren't real hermes_cli endpoints.
|
||||
# Fall back to the custom_providers entries from config.yaml so
|
||||
@@ -21703,11 +22000,13 @@ def _handle_live_models(handler, parsed):
|
||||
# Collect config-specified model IDs separately so they don't
|
||||
# prevent the live fetch below from running (#3718).
|
||||
_config_ids = []
|
||||
_allowlist_ids = []
|
||||
if provider == "custom" or provider.startswith("custom:"):
|
||||
for _cp in _custom_provider_entries_for_request():
|
||||
if custom_provider_entry is None:
|
||||
custom_provider_entry = _cp
|
||||
_config_ids.extend(_custom_provider_model_ids(_cp))
|
||||
_allowlist_ids.extend(_custom_provider_allowlist_ids(_cp))
|
||||
|
||||
# Always try live fetch for custom providers — config entries are a
|
||||
# fallback, not a replacement. The live endpoint should return ALL
|
||||
@@ -21778,15 +22077,39 @@ def _handle_live_models(handler, parsed):
|
||||
except Exception as _fetch_err:
|
||||
logger.debug("Live fetch from custom provider failed: %s", _fetch_err)
|
||||
|
||||
# If live fetch succeeded, merge with config entries (live takes
|
||||
# priority). If live fetch failed, fall back to config-only list.
|
||||
# If live fetch succeeded, filter the live catalog down to the
|
||||
# config-declared models allowlist first, then append any
|
||||
# allowlisted models the live endpoint didn't return. Custom
|
||||
# providers (esp. New-API-style gateways) expose their ENTIRE
|
||||
# catalog via /v1/models — including image/audio models that
|
||||
# are not chat models. The picker must not surface models the
|
||||
# user never declared in config.yaml. Only a NON-EMPTY explicit
|
||||
# ``models`` allowlist gates the filter — a provider configured
|
||||
# with just a singular ``model`` (no ``models`` list) keeps the
|
||||
# unfiltered live catalog. When no allowlist is configured,
|
||||
# return the live list as-is (preserves the pre-filter
|
||||
# discovery behaviour).
|
||||
#
|
||||
# An empty allowlist (``models: []``, ``models: "[]"``, or a
|
||||
# value that decodes to no usable ids) is deliberately treated
|
||||
# as "not configured", NOT as "allow nothing". Gating on
|
||||
# declared-ness instead would emit an EMPTY picker and make the
|
||||
# provider unselectable — a harder failure than surfacing a few
|
||||
# extra models, and unrecoverable from the UI because
|
||||
# ``custom_providers`` is hand-edited in config.yaml (the WebUI
|
||||
# has no write path for it). ``[]`` in practice means a
|
||||
# leftover/placeholder key, not an intentional deny-all, and no
|
||||
# deny-all use case exists: a provider the user wants hidden is
|
||||
# removed from ``custom_providers`` outright.
|
||||
if ids:
|
||||
_live_set = set(ids)
|
||||
for _cid in _config_ids:
|
||||
if _cid not in _live_set:
|
||||
ids.append(_cid)
|
||||
if _allowlist_ids:
|
||||
_allowlist_set = set(_allowlist_ids)
|
||||
ids = [m for m in ids if m in _allowlist_set] or []
|
||||
for _aid in _allowlist_ids:
|
||||
if _aid not in ids:
|
||||
ids.append(_aid)
|
||||
else:
|
||||
ids = list(_config_ids)
|
||||
ids = list(_allowlist_ids or _config_ids)
|
||||
|
||||
# ── OpenAI-compat live fetch fallback ──────────────────────────────────
|
||||
# When provider_model_ids() is unavailable or returns [] for a provider
|
||||
@@ -22843,6 +23166,54 @@ def _prepare_chat_start_session_for_stream(
|
||||
s.save()
|
||||
|
||||
|
||||
def _cleanup_chat_start_launch_failure(session, stream_id: str) -> None:
|
||||
"""Release state registered before a worker thread successfully starts."""
|
||||
clear_session_writeback_owner_if_owned(session.session_id, stream_id)
|
||||
unregister_stream_owner(stream_id)
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop(stream_id, None)
|
||||
STREAM_GOAL_RELATED.pop(stream_id, None)
|
||||
# The session-field reset needs the same concurrency discipline as the
|
||||
# registry half: hold the per-session lock and re-resolve the canonical
|
||||
# session before clearing anything. Mutating the passed-in stale object
|
||||
# could wipe a concurrent successor turn's pending fields, and saving it
|
||||
# could resurrect a session deleted while the launch was failing. Same
|
||||
# pattern as the #1533 race fix (routes.py:3077) and the anchor-scene
|
||||
# write guard (routes.py:5140).
|
||||
#
|
||||
# This runs while the original launch failure is being handled, so it must
|
||||
# never raise. Lock acquisition and session resolution can fail on their own
|
||||
# (I/O, deserialization), and an escaping error here would mask the launch
|
||||
# failure the caller is about to report while leaving the reset half done.
|
||||
try:
|
||||
with _get_session_agent_lock(session.session_id):
|
||||
try:
|
||||
canonical = get_session(session.session_id)
|
||||
except KeyError:
|
||||
return # session deleted while the thread launch was failing
|
||||
if getattr(canonical, "active_stream_id", None) != stream_id:
|
||||
return # a successor turn already owns the session
|
||||
canonical.active_stream_id = None
|
||||
canonical.pending_user_message = None
|
||||
canonical.pending_attachments = []
|
||||
canonical.pending_started_at = None
|
||||
canonical.pending_user_source = None
|
||||
try:
|
||||
canonical.save()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to persist chat-start cleanup after worker launch failure for %s",
|
||||
stream_id,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to reset session state after worker launch failure for %s",
|
||||
stream_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _is_hidden_empty_session(s) -> bool:
|
||||
return (
|
||||
getattr(s, "title", "Untitled") == "Untitled"
|
||||
@@ -23426,6 +23797,7 @@ def _start_chat_stream_for_session(
|
||||
_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)
|
||||
_cleanup_chat_start_launch_failure(s, stream_id)
|
||||
raise
|
||||
response = {
|
||||
"stream_id": stream_id,
|
||||
@@ -23957,6 +24329,17 @@ def _handle_session_compression_recovery_start(handler, body):
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
if not _session_visible_to_active_profile(getattr(source, "profile", None), handler):
|
||||
# #7710: same contract as the detail-load endpoint — 409
|
||||
# ``session_profile_mismatch`` for a known other profile,
|
||||
# 404 only for the None-profile self-heal path.
|
||||
_recovery_session_profile = getattr(source, "profile", None)
|
||||
if _recovery_session_profile:
|
||||
return j(handler, {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": sid,
|
||||
"profile": _recovery_session_profile,
|
||||
}, status=409)
|
||||
return bad(handler, "Session not found", 404)
|
||||
recovery = compression_recovery_payload_for_session(source)
|
||||
if not recovery:
|
||||
@@ -24356,8 +24739,29 @@ def _handle_chat_start(handler, body, diag=None):
|
||||
# Empty placeholders can still be retagged when the
|
||||
# requested profile matches the active request profile.
|
||||
s.profile = requested_profile
|
||||
elif session_profile:
|
||||
# #7710: known other profile → 409 ``session_profile_mismatch``
|
||||
# so the client can offer to switch to it (#5419).
|
||||
# 404 is preserved only for the None-profile
|
||||
# (unknown/legacy) self-heal case.
|
||||
return j(handler, {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": body.get("session_id", ""),
|
||||
"profile": session_profile,
|
||||
}, status=409)
|
||||
else:
|
||||
return bad(handler, "Session not found", 404)
|
||||
# Resolve durable rotations before any workspace/model/pending mutation.
|
||||
# GET navigation adopts the tip; POST never silently replays a user turn.
|
||||
from api.compression_continuation import durable_compression_continuation
|
||||
sealed, continuation = durable_compression_continuation(s)
|
||||
if sealed:
|
||||
return j(handler, {
|
||||
"error": "This session was compressed. Open its continuation before sending.",
|
||||
"code": "session_rotated",
|
||||
"continuation_session_id": continuation,
|
||||
}, status=409)
|
||||
regeneration = None
|
||||
if body.get("regenerate") is True:
|
||||
if any(key in body for key in ("message", "attachments", "keep_count", "prompt", "prompt_index")):
|
||||
@@ -26202,7 +26606,6 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: st
|
||||
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)
|
||||
@@ -26274,9 +26677,6 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: st
|
||||
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:
|
||||
@@ -26337,8 +26737,10 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: st
|
||||
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 approval_id and found_target and run_id:
|
||||
gateway_resolved, _head, _total = resolve_gateway_pending_run(
|
||||
sid, approval_id, run_id, choice
|
||||
)
|
||||
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
|
||||
@@ -28576,6 +28978,16 @@ def _handle_session_import_cli(handler, body):
|
||||
if requested_profile and not _profiles_match(existing_profile, requested_profile):
|
||||
return bad(handler, "Session not found in CLI store", 404)
|
||||
elif not _session_visible_to_active_profile(existing_profile, handler):
|
||||
# #7710: same contract as the detail-load endpoint —
|
||||
# 409 ``session_profile_mismatch`` for a known other
|
||||
# profile, 404 only for the None-profile self-heal path.
|
||||
if existing_profile:
|
||||
return j(handler, {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": sid,
|
||||
"profile": existing_profile,
|
||||
}, status=409)
|
||||
return bad(handler, "Session not found in CLI store", 404)
|
||||
refresh_profile = requested_profile or existing_profile
|
||||
cli_meta = _resolve_cli_import_metadata(
|
||||
|
||||
+58
-9
@@ -45,6 +45,14 @@ TERMINAL_SSE_EVENTS = frozenset({"done", "cancel", "apperror", "error", "stream_
|
||||
SSE_RELAY_CLOSE_EVENTS = frozenset({"stream_end", "cancel", "apperror", "error"})
|
||||
# Back-compat alias used by older call sites / tests.
|
||||
_TERMINAL_SSE_EVENTS = TERMINAL_SSE_EVENTS
|
||||
# Events that are live-UI-only telemetry with no recovery value in the run
|
||||
# journal. They are skipped at WRITE time (never durably journaled, so they
|
||||
# cannot bloat the journal on marathon runs) and filtered at REPLAY time (so
|
||||
# legacy journals that already contain a backlog never stream it to a
|
||||
# reconnecting browser tab). Readers deliberately do NOT filter: cursor math
|
||||
# (``cursor_event_missing`` bound) and the offline-gap coverage check count
|
||||
# journal seqs and must keep seeing every row.
|
||||
REPLAY_SKIPPED_SSE_EVENTS = frozenset({"metering"})
|
||||
_FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC"
|
||||
_FSYNC_MODE_EAGER = "eager"
|
||||
_FSYNC_MODE_TERMINAL_ONLY = "terminal-only"
|
||||
@@ -443,25 +451,45 @@ class RunJournalWriter:
|
||||
self.session_id = _validate_id(session_id, "session_id")
|
||||
self.run_id = _validate_id(run_id, "run_id")
|
||||
self.session_dir = Path(session_dir) if session_dir is not None else None
|
||||
self._path = _run_path(self.session_id, self.run_id, session_dir=self.session_dir)
|
||||
self._lock = _lock_for(self._path)
|
||||
|
||||
def append_sse_event(self, event_name: str, payload=None) -> dict:
|
||||
# Draw from the shared module-level seq cache under the per-path lock so
|
||||
# this writer and any direct append_run_event() call on the same path
|
||||
# agree on one monotonic, gapless sequence.
|
||||
with self._lock:
|
||||
seq = _reserve_next_seq(self._path)
|
||||
def append_sse_event(self, event_name: str, payload=None) -> dict | None:
|
||||
# Live-UI-only telemetry (metering) has no recovery value in the journal:
|
||||
# nothing reads those rows back for recovery, and journaling them at ~10 Hz
|
||||
# on marathon runs balloons the durable file (12+ MB of a single 18 MB run
|
||||
# was metering). Skip the write entirely and return None so callers'
|
||||
# journal-id plumbing (``(journaled or {}).get("event_id")``) is untouched.
|
||||
# Not reserving a seq keeps the remaining journaled seqs contiguous, which
|
||||
# the offline-gap coverage and replay-cursor contiguity checks rely on.
|
||||
if str(event_name or "").strip() in REPLAY_SKIPPED_SSE_EVENTS:
|
||||
return None
|
||||
# Allocate the sequence inside the same per-path transaction that writes
|
||||
# the row. Reserving here, then releasing the lock before append, lets a
|
||||
# concurrent writer put a higher sequence on disk first.
|
||||
return append_run_event(
|
||||
self.session_id,
|
||||
self.run_id,
|
||||
event_name,
|
||||
payload or {},
|
||||
session_dir=self.session_dir,
|
||||
seq=seq,
|
||||
)
|
||||
|
||||
|
||||
def journal_replay_visible(event) -> bool:
|
||||
"""Return True when a journal row should be streamed to a reconnecting tab.
|
||||
|
||||
Live-UI-only telemetry rows (see ``REPLAY_SKIPPED_SSE_EVENTS``) carry no
|
||||
recovery value — replaying a metering backlog only re-paints a stale TPS
|
||||
number while multiplying the reconnect burst size. Writers no longer journal
|
||||
them, but legacy journals may already contain them, so the replay emit sites
|
||||
filter through this predicate. Non-dict rows are passed through (visible) so
|
||||
an unexpected shape can never silently swallow user-visible output.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return True
|
||||
name = str(event.get("event") or event.get("type") or "")
|
||||
return name not in REPLAY_SKIPPED_SSE_EVENTS
|
||||
|
||||
|
||||
def read_run_events(
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
@@ -587,6 +615,27 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict |
|
||||
return None
|
||||
|
||||
|
||||
def find_run_file(run_id: str, *, session_dir: Path | None = None) -> tuple[str, Path] | None:
|
||||
"""Locate a run journal file by run id WITHOUT parsing its body.
|
||||
|
||||
Hot callers that immediately read the full journal (the live-snapshot
|
||||
rebuild) must not pay :func:`find_run_summary`'s full-file parse first:
|
||||
on a long live run the file holds tens of thousands of rows and parsing
|
||||
it twice per rebuild dominated the snapshot cost. Returns
|
||||
``(session_id, path)`` for the first match, or ``None`` when the run id
|
||||
is invalid or no journal exists.
|
||||
"""
|
||||
try:
|
||||
rid = _validate_id(run_id, "run_id")
|
||||
except ValueError:
|
||||
return None
|
||||
root = Path(session_dir) if session_dir is not None else _default_session_dir()
|
||||
journal_root = root / RUN_JOURNAL_DIR_NAME
|
||||
for path in journal_root.glob(f"*/{rid}.jsonl"):
|
||||
return path.parent.name, path
|
||||
return None
|
||||
|
||||
|
||||
def read_session_run_events(
|
||||
session_id: str,
|
||||
*,
|
||||
|
||||
+541
-163
@@ -67,6 +67,7 @@ from api.helpers import (
|
||||
)
|
||||
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
|
||||
from api.compression_recovery import stamp_compression_exhausted_recovery
|
||||
from api.gateway_chat import WEBUI_LOCAL_CHAT_BACKEND
|
||||
from api.metering import meter
|
||||
from api.run_journal import RunJournalWriter
|
||||
from api.todo_state import attach_todo_state, emit_todo_state
|
||||
@@ -196,41 +197,156 @@ def _compact_for_echo_compare(value: str) -> str:
|
||||
return re.sub(r'\s+', '', str(value or ''))
|
||||
|
||||
|
||||
def _strip_compact_echo_suffix(value: str, suffix: str, *, search_window: int = 4096) -> tuple[str, bool]:
|
||||
class _CompactEchoIndex:
|
||||
"""Incremental whitespace-folded index over a growing text buffer.
|
||||
|
||||
The journal-rebuild echo check runs once per interim event against the
|
||||
whole accumulated reasoning transcript. Rescanning the raw buffer each
|
||||
time walks every whitespace character between the tail and the first
|
||||
non-whitespace character — on a whitespace-heavy transcript that span is
|
||||
megabytes, and the per-interim walk turns the replay quadratic (the
|
||||
``_find_compact_echo_suffix_start`` shape measured ~55x slower than
|
||||
master on a production-shaped journal in the #7569 review).
|
||||
|
||||
This class keeps the folded view and the raw cut offsets *incrementally*:
|
||||
|
||||
* ``append`` folds each new chunk once and records, per folded character,
|
||||
its raw index — so the cost of a chunk is proportional to the chunk,
|
||||
never to the buffer.
|
||||
* ``matches_tail`` compares only the candidate against the end of the
|
||||
folded view: O(len(candidate)), with no raw-text walk at all.
|
||||
* ``cut_to`` converts a folded length back to a raw index by bisecting
|
||||
the recorded offsets, so one call returns the same cut point the raw
|
||||
backward walk would have produced.
|
||||
|
||||
``str.isspace`` is used for folding instead of the ``\\s`` pattern used
|
||||
by :func:`_compact_for_echo_compare`. The two agree on every Unicode code
|
||||
point, so the indexed view and the regex-folded view stay consistent.
|
||||
"""
|
||||
|
||||
__slots__ = ('_compact', '_offsets', '_raw_len')
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._compact: list[str] = []
|
||||
self._offsets: list[int] = []
|
||||
self._raw_len = 0
|
||||
|
||||
def append(self, text: str) -> None:
|
||||
"""Fold ``text`` onto the end of the index (one pass, no rescan)."""
|
||||
raw = str(text or '')
|
||||
if not raw:
|
||||
return
|
||||
compact = self._compact
|
||||
offsets = self._offsets
|
||||
base = self._raw_len
|
||||
for i, ch in enumerate(raw):
|
||||
if not ch.isspace():
|
||||
compact.append(ch)
|
||||
offsets.append(base + i)
|
||||
self._raw_len = base + len(raw)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all indexed state (used after the raw text is truncated)."""
|
||||
self._compact.clear()
|
||||
self._offsets.clear()
|
||||
self._raw_len = 0
|
||||
|
||||
@property
|
||||
def compact_length(self) -> int:
|
||||
return len(self._compact)
|
||||
|
||||
def matches_tail(self, suffix: str) -> bool:
|
||||
"""True when the folded ``suffix`` equals the folded tail."""
|
||||
candidate = _compact_for_echo_compare(suffix)
|
||||
if not candidate:
|
||||
return False
|
||||
n = len(candidate)
|
||||
if n > len(self._compact):
|
||||
return False
|
||||
start = len(self._compact) - n
|
||||
compact = self._compact
|
||||
for i in range(n):
|
||||
if compact[start + i] != candidate[i]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def cut_to(self, suffix: str) -> int | None:
|
||||
"""Raw index at which the echo of ``suffix`` starts, else ``None``.
|
||||
|
||||
The returned index is the first raw character of the echo, matching
|
||||
the leftmost-cut semantics of the raw backward walk. Trailing
|
||||
whitespace before the echo is left to the caller's ``rstrip``.
|
||||
"""
|
||||
candidate = _compact_for_echo_compare(suffix)
|
||||
if not candidate:
|
||||
return None
|
||||
n = len(candidate)
|
||||
if n > len(self._compact):
|
||||
return None
|
||||
start = len(self._compact) - n
|
||||
compact = self._compact
|
||||
for i in range(n):
|
||||
if compact[start + i] != candidate[i]:
|
||||
return None
|
||||
return self._offsets[start]
|
||||
|
||||
def compact_view(self) -> str:
|
||||
"""The folded text (materialized on demand — never kept as a string)."""
|
||||
return ''.join(self._compact)
|
||||
|
||||
|
||||
def _find_compact_echo_suffix_start(value: str, suffix: str) -> int | None:
|
||||
"""Return the index where a whitespace-folded ``suffix`` starts at the
|
||||
end of ``value``, or ``None`` when the tail does not echo it.
|
||||
|
||||
The match walks ``value`` and ``suffix`` from the end, skipping
|
||||
whitespace in ``value``; only the echo span itself is inspected, so the
|
||||
cost is linear in the echo length and allocation-free. Unlike a fixed
|
||||
fold window, the walk cannot miss a compact-equivalent suffix whose raw
|
||||
span is stretched by interior whitespace.
|
||||
|
||||
``str.isspace`` is used for the walk instead of the ``\\s`` pattern used
|
||||
by :func:`_compact_for_echo_compare`. The two agree on every Unicode code
|
||||
point, so the folded view and the walk stay consistent.
|
||||
"""
|
||||
candidate = _compact_for_echo_compare(suffix)
|
||||
if not candidate:
|
||||
return None
|
||||
i = len(value) - 1
|
||||
j = len(candidate) - 1
|
||||
while j >= 0:
|
||||
while i >= 0 and value[i].isspace():
|
||||
i -= 1
|
||||
if i < 0 or value[i] != candidate[j]:
|
||||
return None
|
||||
i -= 1
|
||||
j -= 1
|
||||
return i + 1
|
||||
|
||||
|
||||
def _strip_compact_echo_suffix(value: str, suffix: str) -> tuple[str, bool]:
|
||||
"""Remove ``suffix`` from ``value`` when they match after whitespace folding.
|
||||
|
||||
The search window is folded once and the cut point is then located by
|
||||
walking backwards across the echo itself. The previous implementation
|
||||
probed every candidate cut index and re-folded the whole remaining tail for
|
||||
each probe, which is quadratic in the window size: a 6000-character final
|
||||
message cost seconds of CPU, held under the GIL, stalling every other
|
||||
stream in the process.
|
||||
The cut point is located by the same backward walk as
|
||||
:func:`_find_compact_echo_suffix_start`: no fixed search window is
|
||||
involved, so a compact-equivalent suffix is removed no matter how much
|
||||
interior whitespace stretches its raw span. The previous windowed
|
||||
implementation folded a bounded tail on every call; its retired probing
|
||||
variant re-folded the remaining tail per candidate cut index, which is
|
||||
quadratic: a 6000-character final message cost seconds of CPU, held under
|
||||
the GIL, stalling every other stream in the process.
|
||||
|
||||
``str.isspace`` is used for the backwards walk instead of the ``\\s``
|
||||
pattern used by :func:`_compact_for_echo_compare`. The two agree on every
|
||||
Unicode code point, so the folded view and the walk stay consistent.
|
||||
Whitespace sitting between the kept text and the echo is removed by
|
||||
``rstrip``, which lands on the same result as the leftmost cut index the
|
||||
probing loop used to return.
|
||||
"""
|
||||
raw = str(value or '')
|
||||
candidate = _compact_for_echo_compare(suffix)
|
||||
if not raw or not candidate:
|
||||
if not raw:
|
||||
return raw, False
|
||||
tail = raw[-max(len(str(suffix or '')) * 3, search_window):]
|
||||
offset = len(raw) - len(tail)
|
||||
compact_tail = _compact_for_echo_compare(tail)
|
||||
if len(candidate) > len(compact_tail) or not compact_tail.endswith(candidate):
|
||||
start = _find_compact_echo_suffix_start(raw, suffix)
|
||||
if start is None:
|
||||
return raw, False
|
||||
# Consume exactly as many non-whitespace characters as the folded suffix
|
||||
# holds; ``idx`` then sits on the first character of the echo. Whitespace
|
||||
# sitting between the kept text and the echo is removed by ``rstrip``,
|
||||
# which is why this lands on the same result as the leftmost cut index the
|
||||
# probing loop used to return.
|
||||
remaining = len(candidate)
|
||||
idx = len(tail)
|
||||
while remaining and idx:
|
||||
idx -= 1
|
||||
if not tail[idx].isspace():
|
||||
remaining -= 1
|
||||
return raw[: offset + idx].rstrip(), True
|
||||
return raw[:start].rstrip(), True
|
||||
|
||||
|
||||
def _redacted_session_payload_with_full_messages(session, *, tool_calls=None) -> dict | None:
|
||||
@@ -779,6 +895,32 @@ def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# Session turn-lease notices emitted by the Agent (agent/turn_facade_lease.py)
|
||||
# while another Hermes process (gateway, CLI, cron) holds this session's turn
|
||||
# lease. Emitted via ``_emit_status`` (kind ``lifecycle``) while waiting and on
|
||||
# admission, and via ``_emit_warning`` (kind ``warn``) when the wait times out
|
||||
# and the message was not processed.
|
||||
_SESSION_LEASE_WAIT_MARKERS = (
|
||||
'another hermes process is using this session',
|
||||
'still waiting for the other hermes process',
|
||||
'another hermes process kept this session busy',
|
||||
'session is free; loading the latest transcript',
|
||||
)
|
||||
|
||||
|
||||
def _is_session_lease_wait_message(kind: str, message: str) -> bool:
|
||||
"""Return True for Agent session turn-lease wait notices.
|
||||
|
||||
Classification keys on the Agent status kind (``lifecycle`` / ``warn``) so
|
||||
user-authored text can never be promoted to a warning.
|
||||
"""
|
||||
k = str(kind or '').strip().lower()
|
||||
if k not in ('lifecycle', 'warn'):
|
||||
return False
|
||||
m = str(message or '').strip().lower()
|
||||
return any(marker in m for marker in _SESSION_LEASE_WAIT_MARKERS)
|
||||
|
||||
|
||||
def _is_agent_compression_start_status(kind: str, message: str) -> bool:
|
||||
"""Return True only for real Hermes context-compression start notices.
|
||||
|
||||
@@ -4378,9 +4520,10 @@ def _title_language_mismatch(user_text: str, title: str) -> bool:
|
||||
short and frequently embed a borrowed Latin technical term (e.g. a CJK
|
||||
title containing the word "Python"), the title side uses a proportion
|
||||
threshold (>=35% of the title's alphabetic characters in a non-start
|
||||
script, min 2 chars) rather than a strict majority -- so a CJK title with
|
||||
one English word still trips, while an English title with a single
|
||||
foreign place-name does not.
|
||||
script, min 2 chars) rather than a strict majority. CJK titles with
|
||||
borrowed Latin terms are allowed when the title also contains CJK
|
||||
characters (#7693), but pure-Latin titles for CJK conversations are
|
||||
still rejected.
|
||||
2. The legacy German-start → English-title heuristic, preserved verbatim so
|
||||
the original behavior keeps working for same-script (latin) drift that
|
||||
the script check can't see.
|
||||
@@ -4390,13 +4533,26 @@ def _title_language_mismatch(user_text: str, title: str) -> bool:
|
||||
return False
|
||||
|
||||
# (1) Cross-script mismatch — language-agnostic.
|
||||
# CJK text routinely borrows Latin product/technical terms (e.g. "WeChat
|
||||
# Pay", "Python", "ProRes RAW"), so when the user writes in CJK, Latin
|
||||
# characters in the title are acceptable as long as the title also
|
||||
# contains CJK — i.e. the title is genuinely mixed, not pure drift.
|
||||
# A pure-Latin title for a CJK conversation is still rejected.
|
||||
# Unrelated scripts (Cyrillic, Arabic, Greek …) are always flagged.
|
||||
user_script = _dominant_script(user_text)
|
||||
if user_script:
|
||||
title_counts = _script_counts(candidate)
|
||||
title_total = sum(title_counts.values())
|
||||
if title_total >= 2:
|
||||
for script, n in title_counts.items():
|
||||
if script != user_script and n >= 2 and (n / title_total) >= 0.35:
|
||||
if script == user_script:
|
||||
continue
|
||||
# When user writes in CJK, Latin in the title is a borrowed
|
||||
# term as long as the title also contains CJK characters.
|
||||
if user_script == 'cjk' and script == 'latin':
|
||||
if title_counts.get('cjk', 0) >= 2:
|
||||
continue
|
||||
if n >= 2 and (n / title_total) >= 0.35:
|
||||
return True
|
||||
|
||||
# (2) Legacy same-script German→English heuristic.
|
||||
@@ -7949,7 +8105,7 @@ def _extract_tool_calls_from_messages(messages, live_tool_calls=None):
|
||||
pending_names[tid] = part.get('name', '')
|
||||
pending_args[tid] = part.get('input', {})
|
||||
pending_asst_idx[tid] = msg_idx
|
||||
for tc in m.get('tool_calls', []):
|
||||
for tc in m.get('tool_calls') or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
tid = tc.get('id', '') or tc.get('call_id', '')
|
||||
@@ -9169,7 +9325,32 @@ def _run_agent_streaming(
|
||||
"""
|
||||
_turn_route_model = model
|
||||
_turn_route_provider = model_provider
|
||||
cancel_event = threading.Event()
|
||||
q = peek_stream(stream_id)
|
||||
if q is not None:
|
||||
# Snapshot lookup is not admission: Stop can detach the stream before
|
||||
# we publish the initial run. Register ownership and its retained cancel
|
||||
# signal on the same STREAMS_LOCK edge used by cancellation.
|
||||
with STREAMS_LOCK:
|
||||
cancel_event = CANCEL_FLAGS.get(stream_id, cancel_event)
|
||||
if stream_id not in STREAMS or cancel_event.is_set():
|
||||
q = None
|
||||
else:
|
||||
CANCEL_FLAGS[stream_id] = cancel_event
|
||||
STREAM_PARTIAL_TEXT[stream_id] = ''
|
||||
STREAM_REASONING_TEXT[stream_id] = ''
|
||||
STREAM_LIVE_TOOL_CALLS[stream_id] = []
|
||||
register_active_run(
|
||||
stream_id,
|
||||
session_id=session_id,
|
||||
started_at=time.time(),
|
||||
phase="starting",
|
||||
workspace=str(workspace),
|
||||
model=model,
|
||||
provider=model_provider,
|
||||
ephemeral=bool(ephemeral),
|
||||
backend=WEBUI_LOCAL_CHAT_BACKEND,
|
||||
)
|
||||
if q is None:
|
||||
# The stream was cancelled before the worker started; the route layer
|
||||
# already registered the stream owner, so release it here to avoid
|
||||
@@ -9183,16 +9364,6 @@ def _run_agent_streaming(
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
register_active_run(
|
||||
stream_id,
|
||||
session_id=session_id,
|
||||
started_at=time.time(),
|
||||
phase="starting",
|
||||
workspace=str(workspace),
|
||||
model=model,
|
||||
provider=model_provider,
|
||||
ephemeral=bool(ephemeral),
|
||||
)
|
||||
try:
|
||||
run_journal = RunJournalWriter(session_id, stream_id)
|
||||
except Exception:
|
||||
@@ -9224,14 +9395,6 @@ def _run_agent_streaming(
|
||||
# profile's mcp_servers because os.environ['HERMES_HOME'] hadn't been
|
||||
# rewritten yet. See https://github.com/nesquena/hermes-webui/issues/1968.
|
||||
|
||||
# Sprint 10: create a cancel event for this stream
|
||||
cancel_event = threading.Event()
|
||||
with STREAMS_LOCK:
|
||||
CANCEL_FLAGS[stream_id] = cancel_event
|
||||
STREAM_PARTIAL_TEXT[stream_id] = '' # start accumulating partial text (#893)
|
||||
STREAM_REASONING_TEXT[stream_id] = '' # start accumulating reasoning trace (#1361 §A)
|
||||
STREAM_LIVE_TOOL_CALLS[stream_id] = [] # start accumulating tool calls (#1361 §B)
|
||||
|
||||
agent = None
|
||||
_live_prompt_estimate_tokens = [0]
|
||||
_live_prompt_exact_tokens = [0]
|
||||
@@ -9533,8 +9696,42 @@ def _run_agent_streaming(
|
||||
_metering_thread = threading.Thread(target=_metering_ticker, daemon=True)
|
||||
|
||||
_success_writeback_committed = False
|
||||
_steer_settled = False
|
||||
_returned_pending_steer = []
|
||||
|
||||
def _remember_pending_steer_result(value):
|
||||
text = value.get('pending_steer') if isinstance(value, dict) else None
|
||||
if isinstance(text, str) and text:
|
||||
_returned_pending_steer.append(text)
|
||||
|
||||
def _settle_pending_steer():
|
||||
# Single terminal boundary for success, returned errors, exceptions and
|
||||
# successful self-heal. Admission closes before the last slot consumer;
|
||||
# registry locks never cover SSE writes or Agent drain callbacks.
|
||||
nonlocal _steer_settled
|
||||
with STREAMS_LOCK:
|
||||
if _steer_settled:
|
||||
return
|
||||
_steer_settled = True
|
||||
target = AGENT_INSTANCES.get(stream_id) or agent
|
||||
if stream_id in STREAMS and not cancel_event.is_set():
|
||||
update_active_run(stream_id, phase="finalizing")
|
||||
leftovers = list(_returned_pending_steer)
|
||||
try:
|
||||
drain = getattr(target, '_drain_pending_steer', None)
|
||||
text = drain() if callable(drain) else None
|
||||
if text:
|
||||
leftovers.append(str(text))
|
||||
except Exception:
|
||||
logger.debug("Failed to drain pending steer for session %s", session_id)
|
||||
if leftovers:
|
||||
put('pending_steer_leftover', {
|
||||
'session_id': session_id, 'text': '\n'.join(leftovers),
|
||||
})
|
||||
|
||||
def put(event, data):
|
||||
if event in ('done', 'apperror', 'stream_end'):
|
||||
_settle_pending_steer()
|
||||
# If cancelled, drop all further events except the cancel event itself
|
||||
if cancel_event.is_set() and not _success_writeback_committed and event not in ('cancel', 'apperror'):
|
||||
return
|
||||
@@ -9558,7 +9755,7 @@ def _run_agent_streaming(
|
||||
except Exception:
|
||||
logger.debug("Failed to note event_id %s for stream %s", event_id, stream_id, exc_info=True)
|
||||
try:
|
||||
queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
q.put_nowait(queue_item)
|
||||
except Exception:
|
||||
logger.debug("Failed to put event to queue")
|
||||
@@ -9605,6 +9802,11 @@ def _run_agent_streaming(
|
||||
return
|
||||
# Pass through rate-limit and fallback messages so the frontend can
|
||||
# show them as warnings via the existing messages.js 'warning' listener.
|
||||
# Session turn-lease waits (another Hermes process owns this session)
|
||||
# use the same channel so a delayed turn explains itself.
|
||||
if _is_session_lease_wait_message(_kind, _message):
|
||||
put('warning', {'type': 'session_lease_wait', 'message': _message})
|
||||
return
|
||||
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
|
||||
if _is_fallback_notice:
|
||||
put('warning', {'type': 'fallback', 'message': _message})
|
||||
@@ -9620,6 +9822,84 @@ def _run_agent_streaming(
|
||||
_streaming_skill_home_snapshot = None
|
||||
_restore_streaming_skill_home_modules = False
|
||||
_acquired_streaming_skill_home_patch_lock = False
|
||||
def _register_agent_if_current(candidate, cache_signature=None):
|
||||
nonlocal agent
|
||||
# Every constructor, including both credential self-heal branches, must
|
||||
# share the Stop publication edge. CANCEL_FLAGS may already be detached;
|
||||
# the worker-retained event and stream membership remain authoritative.
|
||||
with STREAMS_LOCK:
|
||||
cancelled = cancel_event.is_set() or stream_id not in STREAMS
|
||||
if not cancelled:
|
||||
AGENT_INSTANCES[stream_id] = candidate
|
||||
# Check and publication share one admission, not a check followed
|
||||
# by an unlocked cache/lifecycle write that could undo Stop.
|
||||
if cache_signature is not None:
|
||||
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE[session_id] = (candidate, cache_signature)
|
||||
SESSION_AGENT_CACHE.move_to_end(session_id)
|
||||
if not ephemeral:
|
||||
try:
|
||||
# Pure in-memory bookkeeping; no Agent/provider call.
|
||||
from api.session_lifecycle import register_agent
|
||||
register_agent(session_id, candidate)
|
||||
except Exception:
|
||||
logger.debug("Lifecycle register_agent failed for session %s", session_id, exc_info=True)
|
||||
if not cancelled:
|
||||
return True
|
||||
# A cache hit is a borrowed reusable object, not this worker's private
|
||||
# candidate. Stop may already have let a successor reuse it. Rejected
|
||||
# cache-hit admission must not interrupt that other turn.
|
||||
if cache_signature is None and not ephemeral:
|
||||
if agent is candidate:
|
||||
agent = None
|
||||
return False
|
||||
# Newly constructed candidates were never published on this failed
|
||||
# admission; they cannot have been borrowed by a successor from us.
|
||||
# No Agent call, session persistence or SSE write under registry locks.
|
||||
try:
|
||||
candidate.interrupt("Cancelled before start")
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt cancelled candidate agent")
|
||||
return False
|
||||
|
||||
def _agent_can_invoke(candidate):
|
||||
nonlocal agent
|
||||
# Prompt preparation and LRU cleanup can yield after registration. Admit
|
||||
# each invocation again at its point of use; never hold registry locks
|
||||
# across run_conversation or interrupt. A later Stop interrupts the
|
||||
# already-admitted invocation through the registered Agent as usual.
|
||||
from api.config import (
|
||||
SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK,
|
||||
SESSION_WRITEBACK_OWNERS, SESSION_WRITEBACK_OWNERS_LOCK,
|
||||
)
|
||||
with STREAMS_LOCK:
|
||||
current = (not cancel_event.is_set() and stream_id in STREAMS
|
||||
and AGENT_INSTANCES.get(stream_id) is candidate)
|
||||
if not current:
|
||||
if AGENT_INSTANCES.get(stream_id) is candidate:
|
||||
AGENT_INSTANCES.pop(stream_id, None)
|
||||
# Agent identity is not turn identity: a successor can reuse the
|
||||
# same cached object. Use the existing cancellation-surviving
|
||||
# ownership record, holding its lock through cache retirement.
|
||||
# Missing ownership also fails closed (successor may have ended).
|
||||
with SESSION_WRITEBACK_OWNERS_LOCK:
|
||||
if SESSION_WRITEBACK_OWNERS.get(session_id) == stream_id:
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
entry = SESSION_AGENT_CACHE.get(session_id)
|
||||
if entry and entry[0] is candidate:
|
||||
SESSION_AGENT_CACHE.pop(session_id, None)
|
||||
# Dirty memory segments keep their original owner.
|
||||
from api.session_lifecycle import unregister_agent
|
||||
unregister_agent(session_id)
|
||||
# This invocation never started. Stop owns any required interrupt of the
|
||||
# running Agent; a late duplicate interrupt could hit its new borrower.
|
||||
# Drop our local borrowed handle too: final Steer drain must not reach
|
||||
# the successor through the old worker's fallback `agent` reference.
|
||||
if not current and agent is candidate:
|
||||
agent = None
|
||||
return current
|
||||
|
||||
# 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).
|
||||
@@ -9964,6 +10244,14 @@ def _run_agent_streaming(
|
||||
# (#3587) replaces the flat _reasoning_text string so each intermediate
|
||||
# assistant turn (before tool calls) keeps its own reasoning segment.
|
||||
_reasoning_segments: dict = {}
|
||||
# Incremental folded indexes mirroring each reasoning buffer. The
|
||||
# live echo strip consults them instead of re-walking the raw
|
||||
# buffers: an unbounded raw scan of the growing transcript is
|
||||
# quadratic on whitespace-heavy output (#7569 review). Each index
|
||||
# is fed on every append and re-indexed after a strip.
|
||||
_reasoning_segment_indexes: dict = {}
|
||||
_stream_reasoning_index = _CompactEchoIndex()
|
||||
_reasoning_buffer_index = _CompactEchoIndex()
|
||||
_current_reasoning_idx = 0
|
||||
_tool_boundary_advanced = False
|
||||
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
|
||||
@@ -9986,6 +10274,10 @@ def _run_agent_streaming(
|
||||
if _reasoning_buffer[0]:
|
||||
put('reasoning', {'text': _reasoning_buffer[0]})
|
||||
_reasoning_buffer[0] = ''
|
||||
# The folded index mirrors this buffer 1:1 — dropping the
|
||||
# text without dropping the index would leave it describing
|
||||
# text that is no longer there.
|
||||
_reasoning_buffer_index.reset()
|
||||
|
||||
|
||||
def _emit_metering():
|
||||
@@ -10025,30 +10317,37 @@ def _run_agent_streaming(
|
||||
nonlocal _reasoning_segments
|
||||
removed = False
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
next_text, did_remove = _strip_compact_echo_suffix(
|
||||
STREAM_REASONING_TEXT.get(stream_id, ''),
|
||||
text,
|
||||
)
|
||||
if did_remove:
|
||||
cut = _stream_reasoning_index.cut_to(text)
|
||||
if cut is not None:
|
||||
next_text = STREAM_REASONING_TEXT.get(stream_id, '')[:cut].rstrip()
|
||||
STREAM_REASONING_TEXT[stream_id] = next_text
|
||||
_stream_reasoning_index.reset()
|
||||
_stream_reasoning_index.append(next_text)
|
||||
removed = True
|
||||
next_buffer, did_remove_buffer = _strip_compact_echo_suffix(_reasoning_buffer[0], text)
|
||||
if did_remove_buffer:
|
||||
cut = _reasoning_buffer_index.cut_to(text)
|
||||
if cut is not None:
|
||||
next_buffer = _reasoning_buffer[0][:cut].rstrip()
|
||||
_reasoning_buffer[0] = next_buffer
|
||||
_reasoning_buffer_index.reset()
|
||||
_reasoning_buffer_index.append(next_buffer)
|
||||
removed = True
|
||||
for idx in (_current_reasoning_idx, _current_reasoning_idx - 1):
|
||||
if idx not in _reasoning_segments:
|
||||
continue
|
||||
next_segment, did_remove_segment = _strip_compact_echo_suffix(
|
||||
_reasoning_segments.get(idx, ''),
|
||||
text,
|
||||
)
|
||||
if not did_remove_segment:
|
||||
segment_index = _reasoning_segment_indexes.get(idx)
|
||||
if segment_index is None:
|
||||
continue
|
||||
cut = segment_index.cut_to(text)
|
||||
if cut is None:
|
||||
continue
|
||||
next_segment = _reasoning_segments.get(idx, '')[:cut].rstrip()
|
||||
if next_segment:
|
||||
_reasoning_segments[idx] = next_segment
|
||||
segment_index.reset()
|
||||
segment_index.append(next_segment)
|
||||
else:
|
||||
_reasoning_segments.pop(idx, None)
|
||||
_reasoning_segment_indexes.pop(idx, None)
|
||||
removed = True
|
||||
break
|
||||
return removed
|
||||
@@ -10112,15 +10411,21 @@ def _run_agent_streaming(
|
||||
_reasoning_segments[_current_reasoning_idx] = (
|
||||
_reasoning_segments.get(_current_reasoning_idx, '') + reasoning_delta
|
||||
)
|
||||
# Keep the folded index in step with the segment text.
|
||||
_reasoning_segment_indexes.setdefault(
|
||||
_current_reasoning_idx, _CompactEchoIndex()
|
||||
).append(reasoning_delta)
|
||||
# Mirror full concatenation to shared dict so cancel_stream() can persist
|
||||
# it (#1361 §A). Cancel only creates one partial message, so the flat
|
||||
# concatenation is correct there.
|
||||
# Lock-free GIL-atomic mirror — see the STREAMS_LOCK contract in on_token.
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
STREAM_REASONING_TEXT[stream_id] += reasoning_delta
|
||||
_stream_reasoning_index.append(reasoning_delta)
|
||||
# Accumulate into a coalescing buffer so every delta reaches the
|
||||
# browser — reasoning deltas are incremental, not idempotent.
|
||||
_reasoning_buffer[0] += reasoning_delta
|
||||
_reasoning_buffer_index.append(reasoning_delta)
|
||||
# Throttle reasoning SSE events to ~10 Hz to avoid overwhelming the
|
||||
# frontend renderer. Each event triggers _parseStreamState() which
|
||||
# scans the full accumulated text — 10k+ reasoning tokens/second
|
||||
@@ -10131,6 +10436,10 @@ def _run_agent_streaming(
|
||||
_reasoning_last_put[0] = now
|
||||
put('reasoning', {'text': _reasoning_buffer[0]})
|
||||
_reasoning_buffer[0] = ''
|
||||
# The folded index mirrors this buffer 1:1 — dropping the
|
||||
# text without dropping the index would leave it describing
|
||||
# text that is no longer there.
|
||||
_reasoning_buffer_index.reset()
|
||||
# Track reasoning deltas in the meter so live TPS reflects all AI output.
|
||||
_metering_reasoning_deltas[0] += 1
|
||||
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
|
||||
@@ -10239,10 +10548,14 @@ def _run_agent_streaming(
|
||||
_reasoning_segments[_current_reasoning_idx] = (
|
||||
_reasoning_segments.get(_current_reasoning_idx, '') + reason_delta
|
||||
)
|
||||
_reasoning_segment_indexes.setdefault(
|
||||
_current_reasoning_idx, _CompactEchoIndex()
|
||||
).append(reason_delta)
|
||||
# Mirror full concatenation to shared dict (#1361 §A)
|
||||
# Lock-free GIL-atomic mirror — see STREAMS_LOCK contract in on_token.
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
STREAM_REASONING_TEXT[stream_id] += reason_delta
|
||||
_stream_reasoning_index.append(reason_delta)
|
||||
put('reasoning', {'text': reason_delta})
|
||||
_metering_reasoning_deltas[0] += 1
|
||||
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
|
||||
@@ -10809,6 +11122,7 @@ def _run_agent_streaming(
|
||||
# ── Agent cache: reuse across messages in the same session ──
|
||||
# Mirrors gateway _agent_cache. Keeps _user_turn_count alive so
|
||||
# injectionFrequency: "first-turn" actually suppresses after turn 1.
|
||||
_cache_new_agent = False
|
||||
if ephemeral:
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
logger.debug('[webui] Created ephemeral agent for session %s', session_id)
|
||||
@@ -10851,14 +11165,6 @@ def _run_agent_streaming(
|
||||
session_id,
|
||||
_cached_agent_session_identity(_cached_agent),
|
||||
)
|
||||
if agent is not None:
|
||||
# Reopened/cache-hit sessions must register the agent
|
||||
# so later lifecycle commits can find it.
|
||||
try:
|
||||
from api.session_lifecycle import register_agent
|
||||
register_agent(session_id, agent)
|
||||
except Exception:
|
||||
logger.debug("Lifecycle register_agent failed for cached session %s", session_id, exc_info=True)
|
||||
|
||||
if _identity_mismatch_entry is not None:
|
||||
try:
|
||||
@@ -10928,13 +11234,17 @@ def _run_agent_streaming(
|
||||
agent._interrupt_message = None
|
||||
else:
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
# Register the new agent with the memory lifecycle so
|
||||
# its commit_memory_session() can be found later.
|
||||
try:
|
||||
from api.session_lifecycle import register_agent
|
||||
register_agent(session_id, agent)
|
||||
except Exception:
|
||||
logger.debug("Lifecycle register_agent failed for new session %s", session_id, exc_info=True)
|
||||
_cache_new_agent = True
|
||||
|
||||
if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):
|
||||
with _agent_lock:
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
|
||||
# Cache publication is already fenced with Stop above. Keep LRU
|
||||
# eviction and any memory-provider close outside the stream lock.
|
||||
if _cache_new_agent:
|
||||
_evicted_items = []
|
||||
# Snapshot the set of session_ids with a LIVE agent worker
|
||||
# BEFORE taking SESSION_AGENT_CACHE_LOCK, so LRU eviction never
|
||||
@@ -10955,8 +11265,6 @@ def _run_agent_streaming(
|
||||
except Exception:
|
||||
_active_sids = set()
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)
|
||||
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
|
||||
from api.config import SESSION_AGENT_CACHE_MAX
|
||||
# Evict the oldest INACTIVE entries first. Walk LRU order
|
||||
# (front = oldest); skip any session with a live run. If
|
||||
@@ -10985,21 +11293,6 @@ def _run_agent_streaming(
|
||||
logger.debug('[webui] Evicted LRU agent from cache: %s', _evicted_sid)
|
||||
logger.debug('[webui] Created new agent for session %s', session_id)
|
||||
|
||||
# Store agent instance for cancel/interrupt propagation
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = agent
|
||||
# Check if cancel was requested during agent initialization
|
||||
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS[stream_id].is_set():
|
||||
# Cancel arrived during agent creation - interrupt immediately
|
||||
try:
|
||||
agent.interrupt("Cancelled before start")
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt agent before start")
|
||||
with _agent_lock:
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
|
||||
# Prepend workspace context so the agent always knows which directory
|
||||
# to use for file operations, regardless of session age or AGENTS.md defaults.
|
||||
workspace_ctx = _workspace_context_prefix(str(s.workspace))
|
||||
@@ -11259,7 +11552,13 @@ def _run_agent_streaming(
|
||||
)
|
||||
_run_conversation_kwargs["user_message"] = user_message
|
||||
_result_partial_pre_call_context = list(_previous_context_messages)
|
||||
if not _agent_can_invoke(agent):
|
||||
with _agent_lock:
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
result = agent.run_conversation(**_run_conversation_kwargs)
|
||||
_remember_pending_steer_result(result)
|
||||
_active_turn_identity = _resolve_active_turn_authority(
|
||||
_active_turn_identity,
|
||||
result=result,
|
||||
@@ -11740,8 +12039,6 @@ def _run_agent_streaming(
|
||||
if 'credential_pool' in _agent_params:
|
||||
_agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = agent
|
||||
_agent_sig = _compute_agent_cache_signature(
|
||||
resolved_model,
|
||||
resolved_api_key,
|
||||
@@ -11758,10 +12055,11 @@ def _run_agent_streaming(
|
||||
profile_home=_profile_home,
|
||||
safe_profile_runtime_env=_safe_profile_runtime_env,
|
||||
)
|
||||
from api.config import SESSION_AGENT_CACHE as _SAC, SESSION_AGENT_CACHE_LOCK as _SAC_L
|
||||
with _SAC_L:
|
||||
_SAC[session_id] = (agent, _agent_sig)
|
||||
_SAC.move_to_end(session_id)
|
||||
if not _register_agent_if_current(agent, _agent_sig):
|
||||
# Returned-error settlement already owns _agent_lock.
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
# Retry the conversation once with fresh credentials
|
||||
_self_healed = True
|
||||
_token_sent = False
|
||||
@@ -11794,7 +12092,13 @@ def _run_agent_streaming(
|
||||
_result_partial_pre_call_context = list(
|
||||
_heal_context_messages
|
||||
)
|
||||
if not _agent_can_invoke(agent):
|
||||
# Returned-error settlement already owns the lock.
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
_heal_result = agent.run_conversation(**_heal_kwargs)
|
||||
_remember_pending_steer_result(_heal_result)
|
||||
_active_turn_identity = _resolve_active_turn_authority(
|
||||
_active_turn_identity,
|
||||
result=_heal_result,
|
||||
@@ -12741,23 +13045,7 @@ def _run_agent_streaming(
|
||||
else None
|
||||
)
|
||||
# (reasoning trace already attached + saved above, before s.save())
|
||||
# Leftover-steer delivery: if a /steer was queued (via
|
||||
# api/chat/steer) but the agent finished its turn before
|
||||
# reaching a tool-result boundary that would consume it,
|
||||
# the text is still stashed in agent._pending_steer. Drain
|
||||
# it now and emit a pending_steer_leftover SSE event so the
|
||||
# frontend can queue it for the next turn — same fallback
|
||||
# path as the CLI in cli.py:8788-8794.
|
||||
try:
|
||||
_drain_pending_steer = getattr(agent, '_drain_pending_steer', None)
|
||||
_leftover = _drain_pending_steer() if _drain_pending_steer else None
|
||||
if _leftover:
|
||||
put('pending_steer_leftover', {
|
||||
'session_id': session_id,
|
||||
'text': str(_leftover),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("Failed to drain pending steer for session %s", session_id)
|
||||
_settle_pending_steer()
|
||||
# /goal parity: after a successful assistant turn, run the Hermes
|
||||
# GoalManager judge before terminal done/stream_end events. The
|
||||
# frontend surfaces the status line and queues continuation_prompt as
|
||||
@@ -13107,8 +13395,6 @@ def _run_agent_streaming(
|
||||
if 'credential_pool' in _agent_params:
|
||||
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
|
||||
_heal_agent = _AIAgent(**_heal_kwargs)
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = _heal_agent
|
||||
_agent_sig = _compute_agent_cache_signature(
|
||||
resolved_model,
|
||||
resolved_api_key,
|
||||
@@ -13125,10 +13411,11 @@ def _run_agent_streaming(
|
||||
profile_home=_profile_home,
|
||||
safe_profile_runtime_env=_safe_profile_runtime_env,
|
||||
)
|
||||
from api.config import SESSION_AGENT_CACHE as _SAC2, SESSION_AGENT_CACHE_LOCK as _SAC2_L
|
||||
with _SAC2_L:
|
||||
_SAC2[session_id] = (_heal_agent, _agent_sig)
|
||||
_SAC2.move_to_end(session_id)
|
||||
if not _register_agent_if_current(_heal_agent, _agent_sig):
|
||||
with _agent_lock:
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
# Retry the conversation
|
||||
_token_sent = False
|
||||
try:
|
||||
@@ -13160,7 +13447,13 @@ def _run_agent_streaming(
|
||||
_result_partial_pre_call_context = list(
|
||||
_heal_context_messages
|
||||
)
|
||||
if not _agent_can_invoke(_heal_agent):
|
||||
with _agent_lock:
|
||||
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
|
||||
put('cancel', _cancel_event_payload('Cancelled by user'))
|
||||
return
|
||||
_heal_result = _heal_agent.run_conversation(**_heal_kwargs2)
|
||||
_remember_pending_steer_result(_heal_result)
|
||||
_active_turn_identity = _resolve_active_turn_authority(
|
||||
_active_turn_identity,
|
||||
result=_heal_result,
|
||||
@@ -13414,6 +13707,7 @@ def _run_agent_streaming(
|
||||
_error_payload['old_session_id'] = session_id
|
||||
put('apperror', _error_payload)
|
||||
finally:
|
||||
_settle_pending_steer()
|
||||
# #4633/#2476: symmetric metering teardown. begin_session() (top of the
|
||||
# outer try) had no paired end_session(), so zero-token turns leaked a
|
||||
# _sessions[stream_id] entry that get_stats() pruning never reclaims (its
|
||||
@@ -13441,7 +13735,6 @@ def _run_agent_streaming(
|
||||
if (s is not None
|
||||
and getattr(s, 'active_stream_id', None) == stream_id
|
||||
and getattr(s, 'pending_user_message', None)):
|
||||
update_active_run(stream_id, phase="finalizing")
|
||||
_last_resort_sync_from_core(s, stream_id, _agent_lock)
|
||||
_clear_thread_env() # TD1: always clear thread-local context
|
||||
if _streaming_cron_profile_home_token is not None:
|
||||
@@ -13536,13 +13829,68 @@ def _run_agent_streaming(
|
||||
# ============================================================
|
||||
|
||||
|
||||
# Local lifecycle phases with a pending-steer consumer still ahead of them.
|
||||
# Gateway uses its own phase vocabulary and never falls through to local delivery.
|
||||
_LOCAL_STEERABLE_PHASES = frozenset({"starting", "running"})
|
||||
|
||||
|
||||
def _steer_bound_stream(sid: str, stream_id: str, text: str) -> dict | None:
|
||||
"""Deliver to a verified live worker; None retains cache-only compatibility.
|
||||
|
||||
Never perform HTTP writes or cache/database teardown under stream locks.
|
||||
"""
|
||||
from api import config as cfg
|
||||
|
||||
with cfg.STREAMS_LOCK:
|
||||
agent = cfg.AGENT_INSTANCES.get(stream_id)
|
||||
owner = cfg.stream_owner_session_id(stream_id)
|
||||
with cfg.ACTIVE_RUNS_LOCK:
|
||||
run = dict(cfg.ACTIVE_RUNS.get(stream_id) or {})
|
||||
# Gateway owns transport even when no in-process worker is registered.
|
||||
# A reusable local cache entry must never override that authority.
|
||||
if run.get("backend") == "gateway":
|
||||
if (stream_id not in cfg.STREAMS
|
||||
or owner != sid or run.get("session_id") != sid
|
||||
or run.get("phase") == "cancelling"):
|
||||
return {"accepted": False, "fallback": "stream_dead", "stream_id": None}
|
||||
return {"accepted": False, "fallback": "gateway_steer_queued", "stream_id": stream_id}
|
||||
if (stream_id in cfg.STREAMS and owner == sid
|
||||
and run.get("session_id") == sid
|
||||
and run.get("backend") == WEBUI_LOCAL_CHAT_BACKEND
|
||||
and run.get("phase") == "finalizing"):
|
||||
return {"accepted": False, "fallback": "not_running", "stream_id": stream_id}
|
||||
if agent is None:
|
||||
if ((owner and owner != sid)
|
||||
or (run.get("session_id") and run["session_id"] != sid)
|
||||
or (run and run.get("phase") not in _LOCAL_STEERABLE_PHASES)):
|
||||
return {"accepted": False, "fallback": "stream_dead", "stream_id": None}
|
||||
return None
|
||||
# A cache hit cannot override missing or conflicting worker ownership.
|
||||
if (stream_id not in cfg.STREAMS
|
||||
or owner != sid or run.get("session_id") != sid
|
||||
or run.get("phase") not in _LOCAL_STEERABLE_PHASES
|
||||
or run.get("backend") == "gateway"):
|
||||
return {"accepted": False, "fallback": "stream_dead", "stream_id": None}
|
||||
if not callable(getattr(agent, "steer", None)):
|
||||
return {"accepted": False, "fallback": "agent_lacks_steer", "stream_id": None}
|
||||
# steer() only stashes input; serializing it with stream teardown keeps
|
||||
# cancel from detaching the selected worker before delivery.
|
||||
try:
|
||||
accepted = bool(agent.steer(text))
|
||||
except Exception:
|
||||
logger.debug("Stream-bound steer failed for session %s", sid, exc_info=True)
|
||||
return {"accepted": False, "fallback": "steer_error", "stream_id": stream_id}
|
||||
return {"accepted": accepted, "fallback": None, "stream_id": stream_id}
|
||||
|
||||
|
||||
def _handle_chat_steer(handler, body: dict) -> bool:
|
||||
"""Inject a /steer payload into the active agent for a session.
|
||||
|
||||
Mirrors the CLI's `/steer <text>` command (cli.py:6140-6155):
|
||||
- Look up the cached AIAgent for the session (PR #1051's
|
||||
SESSION_AGENT_CACHE).
|
||||
- Prefer the active stream's registered AIAgent, with explicit stream
|
||||
and worker ownership; use the cache only for legacy cache-only runs.
|
||||
- Verify a stream is currently active for this session.
|
||||
- Never evict or close an agent while delivering steering input.
|
||||
- Call agent.steer(text) — thread-safe, stashes text in
|
||||
_pending_steer for application at the next tool-result boundary.
|
||||
|
||||
@@ -13571,24 +13919,23 @@ def _handle_chat_steer(handler, body: dict) -> bool:
|
||||
if not text:
|
||||
return bad(handler, "text required")
|
||||
|
||||
evicted_cached_entry = None
|
||||
# A compression rotates agent.session_id without replacing the running
|
||||
# worker. Resolve the stream-bound instance BEFORE consulting the reusable
|
||||
# cache. Steer never owns cache eviction or agent/database teardown.
|
||||
try:
|
||||
session = get_session(sid)
|
||||
except KeyError:
|
||||
session = None
|
||||
stream_id = getattr(session, "active_stream_id", None) or None
|
||||
if stream_id:
|
||||
result = _steer_bound_stream(sid, stream_id, text)
|
||||
if result is not None:
|
||||
return j(handler, result)
|
||||
|
||||
with _cfg.SESSION_AGENT_CACHE_LOCK:
|
||||
cached = _cfg.SESSION_AGENT_CACHE.get(sid)
|
||||
if cached:
|
||||
agent = cached[0]
|
||||
if not _cached_agent_matches_session(agent, sid):
|
||||
evicted_cached_entry = _cfg.SESSION_AGENT_CACHE.pop(sid, None)
|
||||
logger.warning(
|
||||
'[webui] Evicted cached agent before steer due to mismatched session identity: cache_key=%s agent_session_id=%s',
|
||||
sid,
|
||||
_cached_agent_session_identity(agent),
|
||||
)
|
||||
if cached and not _cached_agent_matches_session(cached[0], sid):
|
||||
cached = None
|
||||
if evicted_cached_entry is not None:
|
||||
try:
|
||||
_close_cached_agent_entry_at_session_boundary(sid, evicted_cached_entry)
|
||||
except Exception:
|
||||
logger.debug("Failed to close steer identity-mismatched cached agent for session %s", sid, exc_info=True)
|
||||
if not cached:
|
||||
try:
|
||||
s = get_session(sid)
|
||||
@@ -13634,22 +13981,54 @@ def _handle_chat_steer(handler, body: dict) -> bool:
|
||||
if not active_stream_id:
|
||||
return j(handler, {"accepted": False, "fallback": "not_running",
|
||||
"stream_id": None})
|
||||
with _cfg.STREAMS_LOCK:
|
||||
stream_alive = active_stream_id in _cfg.STREAMS
|
||||
if not stream_alive:
|
||||
# Active stream id is stale — stream has ended; caller falls back
|
||||
return j(handler, {"accepted": False, "fallback": "stream_dead",
|
||||
"stream_id": None})
|
||||
|
||||
# Cache-only compatibility: no registered worker for this stream. The
|
||||
# liveness/ownership revalidation and the enqueue must share the same
|
||||
# stream-ownership edge as Stop (STREAMS_LOCK -> ACTIVE_RUNS_LOCK). A Stop
|
||||
# that claims cancellation between an unlocked check and agent.steer()
|
||||
# would strand guidance this response still reports as accepted. steer()
|
||||
# only stashes input; interrupt, persistence, and HTTP writes stay outside
|
||||
# the lock, and the cached agent is never evicted or closed here.
|
||||
#
|
||||
# Ownership must be proven positively, not merely unrefuted: BOTH the
|
||||
# stream owner AND the active-run session must equal the requesting
|
||||
# session. Missing metadata is ambiguous and fails closed (stream_dead);
|
||||
# an unfenced cache object is never steered on absent ownership.
|
||||
#
|
||||
# The active-run backend is revalidated the same way. With no registered
|
||||
# worker, the backend tag is the only proof that an in-process runtime
|
||||
# owns this run: Gateway resolves to its own outcome, the explicit local
|
||||
# tag may enqueue, and a missing, empty, or foreign backend fails closed.
|
||||
result = {"accepted": False, "fallback": "stream_dead", "stream_id": None}
|
||||
with _cfg.STREAMS_LOCK:
|
||||
if active_stream_id in _cfg.STREAMS:
|
||||
owner = _cfg.stream_owner_session_id(active_stream_id)
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
run = dict((_cfg.ACTIVE_RUNS or {}).get(str(active_stream_id)) or {})
|
||||
owned_stream = bool(owner) and owner == sid
|
||||
owned_run = bool(run.get("session_id")) and run["session_id"] == sid
|
||||
if owned_stream and owned_run and run.get("phase") != "cancelling":
|
||||
backend = run.get("backend")
|
||||
if backend == "gateway":
|
||||
# Gateway owns transport; a local cache object is never steered.
|
||||
result = {"accepted": False, "fallback": "gateway_steer_queued",
|
||||
"stream_id": active_stream_id}
|
||||
elif backend == WEBUI_LOCAL_CHAT_BACKEND and run.get("phase") == "finalizing":
|
||||
result = {"accepted": False, "fallback": "not_running",
|
||||
"stream_id": active_stream_id}
|
||||
elif (backend == WEBUI_LOCAL_CHAT_BACKEND
|
||||
and run.get("phase") in _LOCAL_STEERABLE_PHASES):
|
||||
try:
|
||||
accepted = bool(agent.steer(text))
|
||||
except Exception as exc:
|
||||
logger.debug("agent.steer() raised for session=%s: %s", sid, exc)
|
||||
return j(handler, {"accepted": False, "fallback": "steer_error",
|
||||
"stream_id": active_stream_id})
|
||||
result = {"accepted": False, "fallback": "steer_error",
|
||||
"stream_id": active_stream_id}
|
||||
else:
|
||||
result = {"accepted": accepted, "fallback": None,
|
||||
"stream_id": active_stream_id}
|
||||
|
||||
return j(handler, {"accepted": accepted, "fallback": None,
|
||||
"stream_id": active_stream_id})
|
||||
return j(handler, result)
|
||||
|
||||
|
||||
def cancel_stream(stream_id: str) -> bool:
|
||||
@@ -13762,6 +14141,14 @@ def cancel_stream(stream_id: str) -> bool:
|
||||
# worker is stuck in C-level I/O and never reaches its finally (#6623).
|
||||
update_active_run(stream_id, phase="cancelling", cancelled_at=time.time())
|
||||
|
||||
# Stop and Steer share STREAMS_LOCK -> ACTIVE_RUNS_LOCK ordering.
|
||||
# Publish cancellation and detach ownership before releasing the edge;
|
||||
# later Steer cannot enqueue into a turn already claimed by Stop.
|
||||
if stream_present:
|
||||
streams.pop(stream_id, None)
|
||||
cancel_flags.pop(stream_id, None)
|
||||
agent_instances.pop(stream_id, None)
|
||||
|
||||
# Set WebUI layer cancel flag. Prefer the snapshot captured under the lock;
|
||||
# fall back to a fresh lookup for the ACTIVE_RUNS-only path (stream absent).
|
||||
flag = _snap_flag if _snap_flag is not None else cancel_flags.get(stream_id)
|
||||
@@ -13813,16 +14200,7 @@ def cancel_stream(stream_id: str) -> bool:
|
||||
# worker save and show cancel in the client while persistence says done.
|
||||
_emit_cancel_event = True
|
||||
|
||||
# ── Eager session lock release (fixes #653) ──────────────────────────
|
||||
# Pop stream state now so the 409 guard in routes.py sees the session
|
||||
# as idle and allows new /api/chat/start immediately after cancel,
|
||||
# even if the agent thread is still blocked in a C-level syscall.
|
||||
# The worker thread's finally block uses .pop(key, None) too, so a
|
||||
# double-pop here is safe (no-op).
|
||||
if stream_present:
|
||||
streams.pop(stream_id, None)
|
||||
cancel_flags.pop(stream_id, None)
|
||||
agent_instances.pop(stream_id, None)
|
||||
# Stream ownership was detached under streams_lock before interrupting.
|
||||
# STREAM_PARTIAL_TEXT is intentionally NOT popped here — the agent thread may
|
||||
# still be appending tokens, and the streaming finally block handles cleanup
|
||||
# when the thread exits. We already snapshotted the buffers under streams_lock
|
||||
|
||||
+105
-22
@@ -8,7 +8,12 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import MAX_UPLOAD_BYTES, STATE_DIR
|
||||
from api.helpers import j
|
||||
from api.helpers import (
|
||||
arm_connection_close_if_body_pending,
|
||||
j,
|
||||
unreadable_content_length,
|
||||
unsupported_transfer_encoding,
|
||||
)
|
||||
from api.models import get_session
|
||||
from api.profiles import _profiles_match, get_active_profile_name as _get_active_profile_name
|
||||
from api.workspace import (
|
||||
@@ -45,6 +50,89 @@ def _max_extracted_bytes() -> int:
|
||||
_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
|
||||
|
||||
|
||||
def _reject_before_read(handler, message, status):
|
||||
"""Reject without reading the body; close so unread bytes can't poison HTTP/1.1 reuse.
|
||||
|
||||
Framing decides, exactly as at the other reject-before-read sites: every
|
||||
rejection raised by the preflight above (any ``Transfer-Encoding``, an
|
||||
unreadable or conflicting ``Content-Length``, an over-cap length) already
|
||||
declares a body and still closes. Only the boundary/`parse_multipart` rejection
|
||||
can fire on a request that declares none, and closing there dropped a healthy
|
||||
pooled connection -- on the wire, ``POST /api/upload`` with
|
||||
``Content-Type: application/json`` and no ``Content-Length`` answered 400
|
||||
"No boundary in Content-Type" WITH ``Connection: close`` and the pipelined
|
||||
``GET /api/health/agent`` was never served.
|
||||
"""
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
return j(handler, {'error': message}, status=status)
|
||||
|
||||
|
||||
class _RejectBeforeRead(ValueError):
|
||||
"""A rejection raised before any byte of the request body was consumed.
|
||||
|
||||
Carries the status the caller must answer with. Subclasses ``ValueError`` so
|
||||
that if one ever escapes an unusual call path, each handler's existing
|
||||
``except ValueError`` still answers 400 with the same message instead of
|
||||
turning it into a 500.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status: int):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def _read_multipart_or_reject(handler):
|
||||
"""Framing preflight + body read, shared by all four multipart handlers.
|
||||
|
||||
Returns ``(fields, files)``. Raises ``_RejectBeforeRead`` when the request
|
||||
must be refused before its body is read -- the caller answers through
|
||||
``_reject_before_read()``, which arms ``Connection: close`` first, because the
|
||||
declared body is still queued in ``rfile`` and a reused HTTP/1.1 connection
|
||||
would parse those bytes as the next request line.
|
||||
|
||||
Framing is judged from the headers, not from a present ``Content-Length``:
|
||||
|
||||
* ANY ``Transfer-Encoding`` is refused, ``identity`` included: ``http.server``
|
||||
decodes none of them (``rfile`` is the raw socket) and RFC 9112 section 6.3
|
||||
says a request whose final coding is not ``chunked`` has a length no
|
||||
recipient can determine. A coded request carries no usable Content-Length,
|
||||
so the old code read it as length 0, found no parts, and answered "No file
|
||||
field in request" with keep-alive intact -- leaving every payload byte on
|
||||
the socket to poison the next pooled request. A Content-Length sent BESIDE a
|
||||
coding does not rescue it: that pairing is the smuggling shape 6.3 rejects,
|
||||
and the coding still hides how the body is framed.
|
||||
* a non-numeric or oversized Content-Length cannot be read either, and
|
||||
``parse_multipart()`` validates the boundary and the length before its
|
||||
first ``rfile.read()`` — so every raise below is still pre-read.
|
||||
* two DISAGREEING Content-Length headers read back as whichever came first,
|
||||
so ``Content-Length: 0`` ahead of the real length made the body invisible
|
||||
and left every byte of it on the socket. There is no honest length to
|
||||
drain, so the request is refused before ``rfile`` is touched.
|
||||
"""
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
coding = unsupported_transfer_encoding(handler)
|
||||
if coding is not None:
|
||||
raise _RejectBeforeRead(
|
||||
f'Unsupported Transfer-Encoding: {coding} '
|
||||
'(uploads must send an undecoded body with a Content-Length)',
|
||||
411,
|
||||
)
|
||||
if unreadable_content_length(handler):
|
||||
raise _RejectBeforeRead('Invalid Content-Length', 400)
|
||||
try:
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise _RejectBeforeRead('Invalid Content-Length', 400) from None
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
raise _RejectBeforeRead(
|
||||
f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413)
|
||||
try:
|
||||
return parse_multipart(handler.rfile, content_type, content_length)
|
||||
except ValueError as exc:
|
||||
raise _RejectBeforeRead(str(exc), 400) from None
|
||||
|
||||
|
||||
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
||||
import re as _re, email.parser as _ep
|
||||
# Imported locally (not just module-level) so the function stays
|
||||
@@ -206,11 +294,10 @@ def _write_office_upload_sidecar(workspace: Path, dest: Path, file_bytes: bytes)
|
||||
def handle_upload(handler):
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
session_id = fields.get('session_id', '')
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
@@ -396,11 +483,10 @@ def handle_upload_extract(handler):
|
||||
"""Handle archive upload and extraction."""
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
session_id = fields.get('session_id', '')
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
@@ -428,11 +514,10 @@ def handle_transcribe(handler):
|
||||
import traceback as _tb
|
||||
temp_path = None
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
filename, file_bytes = files['file']
|
||||
@@ -612,12 +697,10 @@ def handle_workspace_upload(handler):
|
||||
"""
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
session_id = fields.get('session_id', '')
|
||||
subpath = fields.get('path', '')
|
||||
|
||||
|
||||
@@ -89,6 +89,15 @@ contributor guidance; it does not change runtime behavior or CI gates.
|
||||
`models_cache.json` refresh timestamps), the stat-identity fallbacks, and the
|
||||
schema/version stamps. Start here before changing model-catalog caching, the
|
||||
`/api/models` cache keys, or the Codex catalog dependency (#2443, #7540).
|
||||
- [`docs/architecture/live-models-allowlist.md`](architecture/live-models-allowlist.md):
|
||||
current contract for how `/api/models/live` filters a custom provider's
|
||||
upstream catalog: the four signals in evaluation order (discovered catalog
|
||||
defers to the live probe, explicit plural `models:` allowlist filters,
|
||||
singular `model:` never gates, no allowlist shows the full catalog), the
|
||||
serialized-list shapes `hermes config set` persists, the deliberate
|
||||
empty-allowlist-is-not-configured rule, and the probe-failure fallback.
|
||||
Start here before changing custom-provider model filtering or
|
||||
discovery-vs-allowlist semantics (#7165, #7404).
|
||||
- [`docs/architecture/profile-home-resolve-cache.md`](architecture/profile-home-resolve-cache.md):
|
||||
current contract for the call-scoped memoization around
|
||||
`_resolve_profile_home_param()` in `api/workspace.py`: what it caches, the
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Live model picker allowlist contract (`/api/models/live`)
|
||||
|
||||
This document records the runtime contract for how `/api/models/live` decides
|
||||
which models a `custom` / `custom:*` provider contributes to the chat model
|
||||
picker. It describes shipped behavior and changes no runtime behavior. It was
|
||||
added because the #7165 review flagged the contract as undocumented: the
|
||||
filtering rules live in `_handle_live_models` (`api/routes.py`) and had only
|
||||
code comments backing them.
|
||||
|
||||
Start here before changing how the live catalog of a custom provider is
|
||||
filtered, how discovery snapshots are distinguished from allowlists, or what
|
||||
the endpoint returns when the upstream probe fails.
|
||||
|
||||
## Why the endpoint filters at all
|
||||
|
||||
OpenAI-compatible gateways (New-API-style aggregators, LM Studio, Ollama
|
||||
proxies) expose their **entire** catalog from `/v1/models`, including
|
||||
image-generation, audio, TTS, and embedding models that are not chat models.
|
||||
The background enrichment path must not surface models the user never opted
|
||||
into — but it also must not hide models the gateway gained since config was
|
||||
last edited. The contract below is how those two goals are reconciled.
|
||||
|
||||
## The four signals, in evaluation order
|
||||
|
||||
For a custom provider the handler consults these signals:
|
||||
|
||||
1. **Discovered catalog → no allowlist (live probe wins).**
|
||||
When the provider entry carries `models_discovered: true` and discovery is
|
||||
allowed (`discover_models` is not an explicit opt-out), the `models:`
|
||||
mapping is a *snapshot Hermes persisted from a past discovery*, not a
|
||||
hand-curated list. It must not gate the live probe: a model the user pulls
|
||||
into LM Studio / Ollama after discovery has to appear in the picker.
|
||||
Detected by the shared predicate
|
||||
`api.config._provider_models_are_discovered_catalog()` — the same
|
||||
predicate the `/api/models` catalog path uses, so both endpoints agree.
|
||||
With this signal, no allowlist is derived and the full live catalog is
|
||||
returned.
|
||||
|
||||
2. **Explicit plural `models:` allowlist → filter.**
|
||||
A non-empty plural `models:` value on an entry that is *not* a discovered
|
||||
catalog expresses "show exactly these models". The live catalog is
|
||||
intersected with it, then any allowlisted model missing from the live
|
||||
response is appended (so an allowlisted-but-offline model stays
|
||||
selectable). This is the #7165 filter. Only **list** and **string**
|
||||
(serialized) shapes are plain allowlists — see "Dict-shaped `models:`"
|
||||
below.
|
||||
|
||||
3. **Singular `model:` → never gates.**
|
||||
The singular field is sticky/default metadata (the model to preselect),
|
||||
not an allowlist. A provider configured only with `model: assistant` keeps
|
||||
the full unfiltered live catalog; gating on it would collapse discovery to
|
||||
a single model.
|
||||
|
||||
4. **No allowlist → full live catalog.**
|
||||
Preserves the pre-#7165 discovery behavior for setups that rely on it.
|
||||
|
||||
### Serialized shapes
|
||||
|
||||
`hermes config set` and the JSON-mode editor persist lists as quoted
|
||||
JSON-array strings (`'["chat-a","chat-b"]'`) or Python literals
|
||||
(`"['chat-a']"`). The plural value is decoded through the shared
|
||||
`_parse_config_string_list()` before id extraction — but only when the value
|
||||
is a `str`; native `dict` / `list` values are walked directly so dict *entries*
|
||||
keep their `id|model|name` metadata (the decoder stringifies members). A
|
||||
dict-only parser drops serialized allowlists to "no allowlist" and floods the
|
||||
picker — the same bug class as `skills.disabled` (#7120 / #7134).
|
||||
|
||||
### Dict-shaped `models:`
|
||||
|
||||
A dict-shaped `models:` mapping (e.g. `{chat-a: {context_length: 128000}}`)
|
||||
is *per-model metadata* written by the Hermes Agent setup flow —
|
||||
`hermes_cli/model_switch.py::_save_custom_provider` and the setup wizard —
|
||||
**not** a catalog narrow. Treating its keys as an allowlist would collapse the
|
||||
live picker to the single saved default (keyless Ollama) while the CLI
|
||||
live-probe shows the full catalog. So a dict is **never** a plain allowlist:
|
||||
|
||||
- without an explicit `discover_models: false`, a dict-shaped `models:`
|
||||
contributes **no allowlist** and the full live catalog is returned;
|
||||
- with `discover_models: false` (bool `False` or the string forms
|
||||
`"false"`/`"no"`/`"0"`, case-insensitive — mirroring
|
||||
`model_switch_providers.py:557`), the dict **keys** are honored as a pinned
|
||||
allowlist.
|
||||
|
||||
This complements the discovered-catalog rule: `models_discovered: true`
|
||||
without the opt-out is already ignored (signal 1). The dict guard covers the
|
||||
Agent-setup shape that is *not* flagged discovered.
|
||||
|
||||
If someone later "fixes" the dict shape back into an always-allowlist, the
|
||||
regression test `test_dict_models_without_discover_false_does_not_gate` fails
|
||||
and forces the trade-off to be re-argued.
|
||||
|
||||
### Empty allowlist semantics
|
||||
|
||||
`models: []`, `models: "[]"`, an empty mapping, or a blank scalar are
|
||||
deliberately treated as **not configured**, never as "allow nothing". The
|
||||
filter runs only on a non-empty allowlist because gating on declared-ness
|
||||
would emit an empty picker and make the provider unselectable — a harder
|
||||
failure than showing a few extra models, and unrecoverable from the UI
|
||||
(`custom_providers` has no WebUI write path; it is hand-edited in
|
||||
config.yaml). There is no deny-all use case: a provider the user wants hidden
|
||||
is removed from `custom_providers` outright.
|
||||
|
||||
### Probe failure / empty probe
|
||||
|
||||
When the upstream probe fails or returns no models, the endpoint falls back
|
||||
to `allowlist or config ids` — the non-empty plural allowlist first, so a
|
||||
singular `model:` outside the allowlist cannot leak in precisely when the
|
||||
probe is unavailable; with no allowlist, the configured ids (singular +
|
||||
plural) are served. A discovered catalog therefore still serves its saved
|
||||
models on a transient probe failure instead of emptying the picker.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_issue3718_live_models_custom_probe.py` — real-handler
|
||||
(`_handle_live_models`) coverage of every signal above: plural list /
|
||||
JSON-array-string / Python-literal / list-of-dicts filtering, dict-shaped
|
||||
`models:` (not gated) and dict + `discover_models: false` (pinned),
|
||||
singular passthrough, malformed safety, discovered-catalog passthrough,
|
||||
`discover_models: false` pinning, and both fallback branches.
|
||||
- `tests/test_issue7404_models_discovered_not_allowlist.py` — the
|
||||
`/api/models` side of the discovered-catalog contract.
|
||||
- `tests/test_byok_model_dropdown.py` — picker rendering, profile-scoped
|
||||
caching, and current-session model preservation around this endpoint.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Sidebar idle / terminal handoff evidence
|
||||
|
||||
## Failure and scope
|
||||
|
||||
The independent `/api/sessions` response can report idle before the chat SSE's
|
||||
terminal frame reaches the page. Three sidebar paths were retiring chat state:
|
||||
idle reconciliation, INFLIGHT purging, and optimistic-row retirement. The idle
|
||||
path clears the Worklog and active stream ID; the subsequent `done` is rejected
|
||||
as stale, so no settled Anchor scene is persisted.
|
||||
|
||||
A real-HTTP barrier reproduces this on both published PR head
|
||||
`75c8ff8d2a991f22389e3e4263ee03fb8d8f67a0` and frozen upstream
|
||||
`b09fded7b8c98b549a49aaaece3721015337d307`. Applying only the `sessions.js`
|
||||
product fix to the frozen upstream also passes the identical oracle: this is a
|
||||
shared lifecycle defect, not evidence that artifact replay introduced it. The
|
||||
historical hosted run lacks this instrumentation, so its exact interleaving
|
||||
cannot be proved retrospectively; its empty-surface/no-persistence signature
|
||||
matches the controlled reproduction.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sh
|
||||
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=settle-worklog-frame-proof \
|
||||
.venv/bin/python tests/browser_conversation_lifecycle.py
|
||||
./scripts/test.sh tests/test_sidebar_terminal_handoff.py -q
|
||||
```
|
||||
|
||||
The existing frame proof now uses a test-only server wrapper to hold the actual
|
||||
`done` HTTP write. The real worker finishes; the real browser receives an idle
|
||||
sidebar response through `renderSessionList()` while its native EventSource
|
||||
remains open. The test verifies pane, INFLIGHT and registry ownership, then
|
||||
releases `done` and retains all existing painted-frame, final-answer, semantic
|
||||
activity and hard-reload assertions. The fixture always releases/cleans up and
|
||||
has a bounded wait. Normal production server startup is unchanged.
|
||||
|
||||
No renderer, terminal payload or persistence response is fabricated. Runtime
|
||||
events come from the existing deterministic localhost Gateway fixture, not a
|
||||
live model/provider. All state and files are disposable. These are browser
|
||||
viewport checks, not physical-device acceptance.
|
||||
|
||||
## Before / after
|
||||
|
||||
Each screenshot is taken after the idle sidebar response, before the terminal
|
||||
HTTP frame is released. The exact published source fails at both widths. The
|
||||
patched source preserves three live activity rows and then passes completion
|
||||
and hard reload. `results.json` records the predicate results without machine
|
||||
paths or credentials.
|
||||
|
||||
| Viewport | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 1440 x 900 | [Empty surface](before-1440.png) | [Owned handoff retained](after-1440.png) |
|
||||
| 390 x 844 | [Empty surface](before-390.png) | [Owned handoff retained](after-390.png) |
|
||||
|
||||
The fix only defers these sidebar cleanups for the current pane's exact OPEN
|
||||
chat transport. Missing, CONNECTING, CLOSED and mismatched sources still take
|
||||
the existing stale recovery path.
|
||||
|
||||
## Missing terminal frame: bounded recovery
|
||||
|
||||
The OPEN-only guard at `85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e` could wait
|
||||
forever when the terminal frame never arrived. The follow-up keeps the live
|
||||
handoff for 1.5 seconds, then probes the exact stream through
|
||||
`/api/chat/stream/status` with an eight-second timeout and no retries.
|
||||
Repeated sidebar hints cannot restart the deadline or create concurrent
|
||||
requests. `active:true` keeps the OPEN owner intact even when the persisted
|
||||
session fields already look idle; only explicit runtime inactivity permits the
|
||||
canonical session restore. A failed or malformed runtime probe uses the
|
||||
existing interrupted-stream path rather than claiming successful settlement.
|
||||
Normal terminal delivery, transport replacement/close, navigation, and a newer
|
||||
optimistic turn invalidate the old request. Already-running stream-end recovery
|
||||
remains the sole recovery owner.
|
||||
|
||||
```sh
|
||||
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=sidebar-idle-missing-terminal \
|
||||
.venv/bin/python tests/browser_conversation_lifecycle.py
|
||||
./scripts/test.sh tests/test_sidebar_idle_stream_recovery.py -q
|
||||
```
|
||||
|
||||
The new browser row keeps the real terminal HTTP barrier closed until cleanup;
|
||||
the client must recover without receiving `done` or `stream_end`. Published
|
||||
`85f1c2d34b` fails the 11-second completion bound with `S.busy` still true.
|
||||
The patched implementation restores the final answer and passes the unchanged
|
||||
painted-frame, semantic activity, persistence and hard-reload checks before the
|
||||
barrier is released. The cloud workflow runs both delayed- and missing-terminal
|
||||
rows. No live provider or production state is used.
|
||||
|
||||
A production Gateway regression also blocks post-turn goal evaluation after
|
||||
the success transcript has already cleared `active_stream_id` and pending
|
||||
fields. During that interval the worker and exact entry in `STREAMS` remain
|
||||
live and no `done` event has been emitted, proving why persisted session flags
|
||||
cannot authorize sidebar settlement on their own.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"mode": "before",
|
||||
"viewport": [
|
||||
1440,
|
||||
900
|
||||
],
|
||||
"exit": 1,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": false,
|
||||
"inflightRetained": false,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "before",
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"exit": 1,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": false,
|
||||
"inflightRetained": false,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "after",
|
||||
"viewport": [
|
||||
1440,
|
||||
900
|
||||
],
|
||||
"exit": 0,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": true,
|
||||
"inflightRetained": true,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "after",
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"exit": 0,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": true,
|
||||
"inflightRetained": true,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -470,6 +470,19 @@ Expected behavior:
|
||||
|
||||
- A session row's running indicator should reflect a real active run or a
|
||||
clearly restorable state, not stale persisted metadata alone.
|
||||
- An idle sidebar response does not prove that the browser has consumed the
|
||||
terminal chat frame. While the current pane still owns its exact OPEN chat
|
||||
transport, list reconciliation and cache purging must preserve that handoff.
|
||||
Missing, connecting, closed, or mismatched transports keep the normal stale
|
||||
state recovery path; a cached stream ID or busy flag is insufficient.
|
||||
OPEN is not proof that a terminal frame will arrive: an idle hint must also
|
||||
schedule bounded recovery without erasing the live scene. Before reading a
|
||||
settled session snapshot, that recovery must check the exact stream's runtime
|
||||
status. A runtime-active stream retains ownership even when persisted
|
||||
`active_stream_id` and pending fields are already clear; only explicit
|
||||
runtime inactivity permits canonical snapshot settlement. Probe failure is a
|
||||
degraded/interrupted state, not evidence of successful completion. A newer
|
||||
turn, replacement source, or received terminal event invalidates the request.
|
||||
- Background completion, cancellation, or failure should be represented without
|
||||
stealing the visible pane from the user.
|
||||
- Session switching should not erase pending live context, in-flight snapshots,
|
||||
|
||||
@@ -183,7 +183,7 @@ when integrating with the live chat SSE relay.
|
||||
| `cancel` | Run cancelled |
|
||||
| `done` | Turn finalized (session payload); title/`stream_end` may follow |
|
||||
| `stream_end` | SSE fence — close the client EventSource |
|
||||
| `metering` | Token/cost metering snapshot |
|
||||
| `metering` | Best-effort live token/cost metering snapshot; not journal-replayed |
|
||||
| `context_status` | Context window / usage status |
|
||||
| `goal` | Goal / plan card update |
|
||||
| `goal_continue` | Goal continuation signal |
|
||||
@@ -228,6 +228,31 @@ A future implementation must replay from the run journal via the existing
|
||||
`_replay_run_journal()` path (in `api/routes.py`) and fall back to the
|
||||
snapshot mechanism when journal entries are unavailable for a given cursor.
|
||||
|
||||
### Live-only metering (implemented replay behavior)
|
||||
|
||||
`metering` is best-effort live telemetry, not durable recovery content.
|
||||
`RunJournalWriter.append_sse_event()` returns `None` for metering without
|
||||
writing a row or allocating a sequence number. Other journaled events retain
|
||||
contiguous sequence numbers. Local-agent and gateway producers carry an
|
||||
explicit per-frame event ID, including `None`, through `StreamChannel`;
|
||||
an unjournaled metering frame emits no new SSE `id:` and must not borrow the
|
||||
previous frame's journal ID. The last-ID fallback is limited to legacy
|
||||
two-element queue entries.
|
||||
|
||||
Existing journals are not rewritten. Direct readers (`read_run_events()` and
|
||||
`read_session_run_events()`) retain legacy metering rows for cursor validation
|
||||
and gap/coverage checks. Both run-level and per-session journal replay emitters
|
||||
apply `journal_replay_visible()` to omit those rows from reconnect delivery.
|
||||
Consequently, replayed legacy event IDs can have gaps where metering was
|
||||
filtered; clients must not infer missing durable content from those gaps.
|
||||
Metering missed while disconnected is not recovered from the journal; fresh
|
||||
live updates remain available. This exception does not remove token, reasoning,
|
||||
tool, or terminal events from durable replay.
|
||||
|
||||
Coverage: `tests/test_run_journal.py`, `tests/test_run_journal_routes.py`,
|
||||
`tests/test_issue4812_session_sse_stream.py`, and
|
||||
`tests/test_stage364_opus_live_sse_event_id.py`.
|
||||
|
||||
## Snapshot fallback
|
||||
|
||||
When the `Last-Event-ID` cursor is evicted, expired, unknown, or refers to a
|
||||
|
||||
@@ -574,6 +574,38 @@ early renderers can avoid chasing `S.messages` during every paint. If settlement
|
||||
later rewrites the transcript message, the anchor must be refreshed from that
|
||||
message instead of allowing the two copies to drift silently.
|
||||
|
||||
### Settled ownership of a restored live turn
|
||||
|
||||
A preserved or snapshotted live-turn node is not evidence of a running turn. The
|
||||
settled assistant message is persisted a few milliseconds before the stream's
|
||||
terminal event clears the active stream id, so a live node whose stream has
|
||||
already ended can survive a transcript rebuild or a session switch and be
|
||||
appended under the settled answer, putting the same answer on screen twice
|
||||
(#6948 follow-up; upstream symptom report #2051).
|
||||
|
||||
A live-turn node is therefore discarded only where the settled transcript is
|
||||
proved to own it: the transcript ends with a settled assistant message, no
|
||||
message still carries a live-projection marker, that settled message's
|
||||
persisted stream identity equals the id of the stream that built the live node
|
||||
— or, only where no scene identity was persisted, the markdown the stream
|
||||
produced equals the persisted message source — and the node carries nothing the
|
||||
settled rebuild could not have produced, meaning no unpersisted tool card,
|
||||
reasoning row or transparent-stream row and no second live segment.
|
||||
|
||||
Ownership is a state proof, never a comparison of two rendered DOM trees: the
|
||||
live body comes from the streaming parser and the settled body from the
|
||||
markdown renderer, and an answer that reads the same as the previous one must
|
||||
not delete a genuinely live turn. An unrecognised node shape keeps the turn.
|
||||
Only the two branches that ADD a turn are gated; a branch that replaces a node
|
||||
cannot duplicate one.
|
||||
|
||||
A settled activity scene projects one answer as well. A `process_prose` row
|
||||
whose text duplicates the scene's final answer — the same text, or a prefix of
|
||||
it within ten percent of its length — is dropped where both settled renderers
|
||||
take their rows, so the answer is not rebuilt as a second assistant segment
|
||||
above itself. Live rendering is unchanged: while the turn streams, the inline
|
||||
live segment is hidden and the prose row is the visible answer.
|
||||
|
||||
## Replay, Reload, And Reconstruction
|
||||
|
||||
Replay/reload should reconstruct the same Assistant Turn Anchor from durable
|
||||
|
||||
@@ -30,6 +30,69 @@ This RFC defines a consistency contract for those layers. It complements the
|
||||
larger run adapter direction in #1925 by documenting what must remain coherent
|
||||
while WebUI still has multiple overlapping state stores.
|
||||
|
||||
## Agent registration after cancellation
|
||||
|
||||
Initial and credential self-heal Agent construction use the same registration
|
||||
boundary. Under `STREAMS_LOCK`, both the worker-retained cancellation event and
|
||||
live stream membership must permit registration. A removed `CANCEL_FLAGS` entry
|
||||
is not permission to restart. If Stop won during initial or self-heal
|
||||
construction, the candidate must not enter the reusable cache or call
|
||||
`run_conversation`. Stream registration, reusable-cache publication and the
|
||||
in-memory lifecycle handle share one atomic Stop admission, using lock order
|
||||
`STREAMS_LOCK` then `SESSION_AGENT_CACHE_LOCK`. Merely moving a cache write
|
||||
after a cancellation check does not protect that check-to-publication gap.
|
||||
|
||||
After prompt preparation and immediately before each initial/self-heal invocation,
|
||||
revalidate the retained cancel event, stream membership and exact registered
|
||||
Agent. If Stop already won, retire a matching reusable entry only while the
|
||||
existing `SESSION_WRITEBACK_OWNERS` record still equals this exact stream.
|
||||
Hold that ownership lock through cache/lifecycle retirement. Object identity is
|
||||
insufficient because successors can reuse the same Agent; absent ownership is
|
||||
not permission either, since a completed successor clears its record. Never
|
||||
clear a successor's cache or lifecycle handle, and do not issue another Agent
|
||||
interrupt for an invocation that never started. Rejected cache-hit registration
|
||||
likewise must not interrupt the borrowed Agent; only a never-published newly
|
||||
constructed candidate may receive construction-cancellation cleanup. Drop the
|
||||
old worker's local borrowed handle as well, so final pending-Steer drain cannot
|
||||
reach a successor through an object-reference fallback.
|
||||
Stop after invocation admission uses the existing Agent interrupt mechanism;
|
||||
registry locks must not span provider or tool execution. LRU eviction/close stays
|
||||
outside the stream lock and retains the existing active-worker policy.
|
||||
|
||||
Interrupt and cancellation finalization occur outside the stream registry lock.
|
||||
Session finalization still owns the session lock: the returned-error path already
|
||||
holds it, while initial registration and the exception path acquire it. Do not
|
||||
reacquire this non-reentrant lock from a branch that already owns it.
|
||||
|
||||
## Run-journal sequence publication
|
||||
|
||||
Within one WebUI process, auto-numbered appends to the same journal allocate
|
||||
sequence numbers and write their rows under the same per-path lock.
|
||||
`RunJournalWriter` delegates both operations to `append_run_event`; it must not
|
||||
reserve a sequence and release the lock before the physical append. Otherwise
|
||||
individually valid rows can reach disk out of order and the session replay
|
||||
reader must reject them as noncontiguous. This does not change caller-supplied
|
||||
sequence semantics, cross-process ownership, or failed-write recovery.
|
||||
|
||||
## Inactive compression continuation recovery
|
||||
|
||||
The Agent profile's SQLite compression lineage owns the canonical continuation,
|
||||
including when Desktop/CLI compressed a session without updating WebUI's
|
||||
`pre_compression_snapshot` sidecar flag. `GET /api/session` may expose the
|
||||
existing `continuation_session_id` hint from that read-only lineage. Automatic
|
||||
`idle_timeout` closure does not hide the continuation; explicit/unknown terminal
|
||||
reasons, foreign-profile rows and delegated/tool children do not authorize it.
|
||||
This read does not reopen sessions or copy ancestor display history into context.
|
||||
|
||||
A stale `POST /api/chat/start` returns HTTP 409 with `code=session_rotated`
|
||||
and the continuation hint before workspace, model, pending-turn or worker
|
||||
mutation. The browser loads the continuation through normal session access
|
||||
checks and restores the rejected text and attachments as a draft. The user
|
||||
sends again explicitly; there is no automatic POST replay or migration of the
|
||||
parent's workspace binding. Clients without this handling must reload the
|
||||
session before retrying. Server wakeups, regeneration semantics and Gateway
|
||||
routing are not silently retargeted by this recovery path.
|
||||
|
||||
## Goals
|
||||
|
||||
- Define the state layers involved in active and recovered WebUI turns.
|
||||
@@ -112,6 +175,23 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
Visible interim assistant progress must remain visible timeline content; a
|
||||
compact Activity disclosure may summarize adjacent tool/debug detail, but it
|
||||
must not be the only place where the user can see emitted progress text.
|
||||
Interim assistant text that duplicates the tail of the accumulated reasoning
|
||||
transcript is stripped from the reasoning copy so the restored snapshot
|
||||
shows the content once. That echo match is whitespace-insensitive and
|
||||
carries no fixed search window: a compact-equivalent suffix is recognized
|
||||
however much interior whitespace stretches its raw span. Both consumers
|
||||
(the live-stream echo path and the journal replay in `api/routes.py`)
|
||||
match through an incremental folded index (`_CompactEchoIndex` in
|
||||
`api/streaming.py`): the folded view and its raw cut offsets are built as
|
||||
each chunk is appended, so a probe costs O(len(candidate)) and never
|
||||
rescans the transcript's whitespace. The retired windowed variants could
|
||||
drop the strip when the span exceeded the window, duplicating the interim
|
||||
text; the retired raw backward walk was correct but re-walked the span per
|
||||
interim event, quadratic on whitespace-heavy transcripts. Regressions:
|
||||
`tests/test_live_snapshot_echo_dedup.py` pins the single-occurrence
|
||||
result, `tests/test_live_snapshot_echo_scan_scaling.py` pins the scaling
|
||||
property (a fixed-size fixture cannot catch a per-interim rescan), and
|
||||
`tests/test_compact_echo_index.py` pins index/oracle equivalence.
|
||||
6. **Compression is not current intent.** Automatic compression summaries and
|
||||
reference cards are recovery/handoff material. They must not be treated as a
|
||||
new user request, active-turn content, or the default visible explanation for
|
||||
|
||||
@@ -56,6 +56,21 @@ infer it from the status code. Clients that only need session updates should
|
||||
use `/api/session/stream` directly; the gateway probe is only relevant for
|
||||
clients that display CLI/TUI/messaging sessions.
|
||||
|
||||
## Gateway watcher projection lifecycle
|
||||
|
||||
The optional gateway stream's watcher owns a cheap database fingerprint and a
|
||||
timestamp for its last full session projection. While clients are subscribed,
|
||||
an unchanged fingerprint can skip the expensive read until the bounded parity
|
||||
interval. With no subscribers the poll loop parks instead of reading the DB.
|
||||
When the final subscriber leaves (including eviction of a full or failed
|
||||
consumer queue), the watcher invalidates both cache fields. A reconnect must
|
||||
therefore trigger a fresh projection even when the database fingerprint has not
|
||||
changed and the parity interval has not elapsed. Final removal also advances a
|
||||
subscriber epoch: an older in-flight DB read cannot restore the invalidated
|
||||
cache or publish its result to a later subscriber cohort. The subscriber lock
|
||||
protects removal, epoch checks and cache commits, but is not held during DB
|
||||
reads, so disconnect is not blocked by a slow projection.
|
||||
|
||||
## Heartbeats and proxy behavior
|
||||
|
||||
- All long-lived streams emit SSE keepalive comment lines on the
|
||||
|
||||
@@ -231,6 +231,36 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again.
|
||||
|
||||
---
|
||||
|
||||
## Agent sessions list slowly (or the `state.db` read index is missing)
|
||||
|
||||
**Symptom.** The sidebar's imported/CLI session list takes seconds per refresh on a large Hermes profile, or a log line says a `state.db` read failed. Sessions still appear; nothing is lost.
|
||||
|
||||
**Why.** Every WebUI reader of the agent's `state.db` (session listing, transcript reads, lineage, gateway watcher, cron sidebar, insights, health) opens it strictly read-only (`file:...?mode=ro`). A reader never upgrades to a write-capable handle and never creates an index: on a multi-GiB `messages` table `CREATE INDEX` holds the SQLite writer lock for minutes and stalls the agent streaming into the same WAL database. When the agent's standard `idx_messages_session` index is missing (older agent, hand-rebuilt or re-imported DB), listings degrade to a bounded one-pass pre-aggregation — slower than the indexed seek, but read-only. A read-only open failure propagates to the caller's existing error boundary (the listing returns empty for that profile) instead of silently reopening the file writable.
|
||||
|
||||
**Diagnostic.**
|
||||
|
||||
```bash
|
||||
sqlite3 "file:$HOME/.hermes/state.db?mode=ro" "PRAGMA index_list(messages)"
|
||||
```
|
||||
|
||||
`idx_messages_session` should be listed. If it is not, the agent has not created it and WebUI will not create it for you.
|
||||
|
||||
**Fix.** Create the covering read indexes in an explicit drained maintenance window: stop the agent (and any gateway/cron runner) writing to that `state.db`, then run:
|
||||
|
||||
```bash
|
||||
python3 scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db --confirm-drained
|
||||
```
|
||||
|
||||
- `--confirm-drained` is mandatory: it is your assertion that no agent turn is running against the database. The tool does not verify it.
|
||||
- `--lock-file PATH` (optional) additionally holds an exclusive non-blocking lock on `PATH` (`flock` on POSIX, `msvcrt.locking` on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without `--lock-file` no lock primitive is required, so the script runs on native Windows as well. On Windows the lock covers byte 0 of `PATH`; a new or empty lock file is initialised with one byte first (an existing lock file is never rewritten).
|
||||
- The tool opens the database `mode=rw` (never `rwc`): a mistyped path raises instead of creating an empty database. Indexes are created inside one `BEGIN IMMEDIATE` transaction and rolled back on any error.
|
||||
- It is idempotent and prints a JSON status per index (`created` / `existing` / `skipped`). `skipped` means this database's schema lacks a column that index keys on (an older agent); the indexes the schema does support are still created. An existing index with a different table, key shape or collation is reported as `Incompatible index` and never replaced; an index that is not covering (`EXPLAIN QUERY PLAN`) is reported as `Index is not covering`.
|
||||
- Windows UNC profiles (`HERMES_HOME=\\server\share\...`) are supported: readers and this tool build the empty-authority URI `file:////server/share/state.db` that the bundled SQLite accepts.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if the listing stays slow after the tool reports `existing` for `idx_messages_session`, if the tool reports `Incompatible index` on an untouched agent-created database, or if a read-only open fails on a local path. Include the tool's JSON output, the `PRAGMA index_list(messages)` result, and the sanitized error text.
|
||||
|
||||
---
|
||||
|
||||
## 404 after login when password auth is enabled
|
||||
|
||||
**Symptom.** After enabling password authentication (`HERMES_WEBUI_PASSWORD`), logging in redirects to `/sessions` and the browser shows a `404 not found` error instead of the chat interface.
|
||||
@@ -241,6 +271,44 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again.
|
||||
|
||||
---
|
||||
|
||||
## "OpenCode Go model picker shows a model that errors when you send" (or is missing newly released models)
|
||||
|
||||
**Symptom.** One of two directions:
|
||||
|
||||
1. A model selected from the OpenCode Go group fails on the first message (`model not found`, `Model is unavailable`, or a region error), even though the picker offered it.
|
||||
2. A newly released OpenCode Go model does not appear in the picker at all, and must be typed into the Custom Model ID box.
|
||||
|
||||
**Why.** The Go picker follows the **live** Go-tier catalog (`https://opencode.ai/zen/go/v1/models`) whenever the installed Hermes Agent is v0.20.5 or newer. That endpoint advertises a superset of what a given tier, key, or region can actually serve — an id can be listed and still fail on send. Conversely, an id the endpoint serves but the WebUI's static fallback list predates is missing when the live path is unavailable (Agent older than v0.20.5, probe failure, or offline); the static list mirrors Hermes core's curated Go catalog and can lag new releases by design.
|
||||
|
||||
**Diagnostic.** Check which path is feeding your picker:
|
||||
|
||||
```bash
|
||||
hermes --version # live path requires >= 0.20.5
|
||||
curl -sS https://opencode.ai/zen/go/v1/models \
|
||||
-H "Authorization: Bearer $OPENCODE_GO_API_KEY" | head -50
|
||||
```
|
||||
|
||||
Interpret the two together:
|
||||
|
||||
- **Failing model absent from the `curl` output** → it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09). A picker can still offer it from a **stale catalog merge**: Hermes core merges its own curated Go list into the live result, and every Agent release *through v0.21.1 (tag `v2026.9.7`)* still carries the delisted id in that list — verified at runtime against v0.21.0, where the live path serves 37 ids including `ox-alpha-free`. The removal is committed on core `main` (2026-09-09 sync) but **no released Agent version includes it yet** — and `main` reports the same `0.21.1` version string as the stale tag, so a version number alone cannot tell you whether the fix is in. Until a release notes the 2026-09-09 catalog sync, the **verified workaround is the config allowlist** (below); selecting the dead entry is harmless to other models (it errors on send, nothing else).
|
||||
- **Failing model present in the `curl` output** → the relay lists it but your tier/region cannot serve it; the picker is behaving correctly. Pin the models you actually use with an explicit allowlist in `config.yaml`, which takes precedence over both the live catalog and the fallback:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
opencode-go:
|
||||
models:
|
||||
- kimi-k3
|
||||
- glm-5.3
|
||||
```
|
||||
|
||||
A lighter per-provider exclude capability is tracked in #7507.
|
||||
- **Missing new model, Agent ≥ v0.20.5** → the live catalog is the source; refresh or check the endpoint with the `curl` above (cold rebuilds are also bounded by a 4-second foreground budget — the first picker open after a restart can serve the last-known list while the live rebuild finishes in the background, so re-open the picker once before concluding it's stale).
|
||||
- **Missing new model, Agent older than v0.20.5** → the static fallback is serving by design; upgrade the Agent to ≥ v0.20.5 so the picker reads the live catalog.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if a model fails on send *and* appears in the `curl` output for your key (a routing problem), or if a model is missing with Agent ≥ v0.20.5 and the live catalog reachable (fallback used when it should not be).
|
||||
|
||||
---
|
||||
|
||||
## Other troubleshooting
|
||||
|
||||
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create the covering read indexes on the agent ``state.db`` in an explicit,
|
||||
drained maintenance window.
|
||||
|
||||
The WebUI read paths (session listing, lineage reads, gateway watcher, cron
|
||||
sidebar, insights, health) never create indexes themselves: ``CREATE INDEX``
|
||||
on a multi-GiB ``messages`` table holds the SQLite writer lock for minutes,
|
||||
which stalls the agent streaming into the same WAL database. Index
|
||||
maintenance is therefore an operator action run while the agent is idle.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db \\
|
||||
--confirm-drained [--lock-file /path/to/agent-activity.lock]
|
||||
|
||||
``--confirm-drained`` is mandatory. When ``--lock-file`` is given, the tool
|
||||
takes an exclusive non-blocking lock on that path (``flock`` on POSIX,
|
||||
``msvcrt.locking`` on Windows) so that a deployment which serialises agent
|
||||
turns on a lock file cannot start a turn mid-rebuild. Without ``--lock-file``
|
||||
no lock primitive is needed, so the script runs on every supported platform.
|
||||
|
||||
Existing indexes are verified for table, key shape and collation and must be
|
||||
covering (``EXPLAIN QUERY PLAN``); an incompatible index is reported, never
|
||||
silently replaced.
|
||||
"""
|
||||
import argparse
|
||||
from contextlib import closing, nullcontext
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
try: # POSIX
|
||||
import fcntl
|
||||
except ImportError: # native Windows
|
||||
fcntl = None
|
||||
try: # Windows
|
||||
import msvcrt
|
||||
except ImportError: # POSIX
|
||||
msvcrt = None
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
from api.agent_sessions import state_db_file_uri # same UNC-aware URI as the readers
|
||||
|
||||
INDEXES = {
|
||||
"idx_messages_session": ("messages", (("session_id", "BINARY"), ("timestamp", "BINARY"))),
|
||||
"idx_messages_session_role": ("messages", (("session_id", "BINARY"), ("role", "NOCASE"))),
|
||||
"idx_sessions_webui_fingerprint": ("sessions", tuple((c, "BINARY") for c in
|
||||
("source", "id", "message_count", "last_activity_at"))),
|
||||
}
|
||||
|
||||
# The only schema gap an older Agent is known to have: sessions.last_activity_at
|
||||
# arrived later than the other keyed columns. An index whose missing columns are
|
||||
# all listed here is reported "skipped"; any other missing column means this is
|
||||
# not an Agent state.db (or a damaged one), and the tool fails closed.
|
||||
_LEGACY_OPTIONAL_COLUMNS = {
|
||||
"idx_sessions_webui_fingerprint": frozenset({"last_activity_at"}),
|
||||
}
|
||||
|
||||
|
||||
def _require_agent_schema(db) -> None:
|
||||
"""Fail closed unless this is an Agent ``state.db``.
|
||||
|
||||
Matching column names alone would accept a look-alike chat database. Every
|
||||
Agent ``state.db`` since the first schema has carried a ``schema_version``
|
||||
row and ``sessions.id`` as the primary key, so require both before any
|
||||
``CREATE INDEX``.
|
||||
"""
|
||||
has_marker = db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
||||
).fetchone()
|
||||
version = None
|
||||
if has_marker:
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(schema_version)")}
|
||||
if "version" in cols:
|
||||
row = db.execute("SELECT version FROM schema_version LIMIT 1").fetchone()
|
||||
version = row[0] if row else None
|
||||
if not isinstance(version, int) or version < 1:
|
||||
raise RuntimeError(
|
||||
"state.db has no Agent schema_version marker; not an Agent state.db, refusing to continue"
|
||||
)
|
||||
pk = [r[1] for r in db.execute("PRAGMA table_info(sessions)") if r[5]]
|
||||
if pk != ["id"]:
|
||||
raise RuntimeError(
|
||||
"state.db sessions table is not keyed on id; not an Agent state.db, refusing to continue"
|
||||
)
|
||||
|
||||
|
||||
def _exclusive_lock(lock_file):
|
||||
if lock_file is None:
|
||||
return nullcontext()
|
||||
if fcntl is None and msvcrt is None:
|
||||
raise RuntimeError("--lock-file needs fcntl (POSIX) or msvcrt (Windows); "
|
||||
"neither is available on this interpreter")
|
||||
handle = open(lock_file, "a+")
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
else:
|
||||
# Lock one byte at offset 0 without blocking; raises OSError when held.
|
||||
# ``a+`` may have just created an empty file, and Windows cannot lock
|
||||
# a range beyond EOF, so make sure byte 0 exists first. An existing
|
||||
# (deployment-owned) lock file is left untouched. Write the byte on
|
||||
# the descriptor: a text-mode ``handle.write("\n")`` becomes two
|
||||
# bytes (``\r\n``) on native Windows.
|
||||
if os.fstat(handle.fileno()).st_size == 0:
|
||||
os.write(handle.fileno(), b"\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except BaseException:
|
||||
handle.close()
|
||||
raise
|
||||
return closing(handle)
|
||||
|
||||
|
||||
def ensure_read_indexes(db_path, *, confirmed_drained=False, lock_file=None):
|
||||
if not confirmed_drained:
|
||||
raise RuntimeError("requires --confirm-drained")
|
||||
db_path = Path(db_path).resolve(strict=True)
|
||||
statuses = {}
|
||||
with _exclusive_lock(lock_file):
|
||||
# mode=rw (not rwc): never create a ghost database at a mistyped path.
|
||||
with closing(sqlite3.connect(state_db_file_uri(db_path) + "?mode=rw", uri=True)) as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Validate the whole Agent schema before creating anything, so a
|
||||
# wrong database fails closed without a partial index set.
|
||||
_require_agent_schema(db)
|
||||
missing_by_index = {}
|
||||
for name, (table, keys) in INDEXES.items():
|
||||
present = {r[1] for r in db.execute(f"PRAGMA table_info({table})")}
|
||||
if not present:
|
||||
raise RuntimeError(f"state.db has no {table!r} table; refusing to continue")
|
||||
missing = {col for col, _ in keys if col not in present}
|
||||
if missing - _LEGACY_OPTIONAL_COLUMNS.get(name, frozenset()):
|
||||
raise RuntimeError(
|
||||
f"state.db {table!r} table lacks Agent column(s) "
|
||||
f"{sorted(missing)}; not an Agent state.db, refusing to continue"
|
||||
)
|
||||
missing_by_index[name] = missing
|
||||
for name, (table, keys) in INDEXES.items():
|
||||
existing = db.execute("SELECT tbl_name FROM sqlite_master WHERE name=?", (name,)).fetchone()
|
||||
if missing_by_index[name]:
|
||||
# Known legacy gap. A same-named index here cannot be the
|
||||
# shape we expect (it keys on the absent column), so it is
|
||||
# incompatible, not something to skip past.
|
||||
if existing:
|
||||
raise RuntimeError(f"Incompatible index: {name}")
|
||||
statuses[name] = "skipped"
|
||||
continue
|
||||
if existing:
|
||||
actual = tuple((r[2], r[4]) for r in db.execute(f"PRAGMA index_xinfo({name})") if r[5])
|
||||
flags = next((r for r in db.execute(f"PRAGMA index_list({table})") if r[1] == name), None)
|
||||
if existing[0] != table or actual != keys or not flags or flags[2] or flags[4]:
|
||||
raise RuntimeError(f"Incompatible index: {name}")
|
||||
statuses[name] = "existing"
|
||||
else:
|
||||
columns = ", ".join(f"{col} COLLATE {collation}" for col, collation in keys)
|
||||
db.execute(f"CREATE INDEX {name} ON {table}({columns})")
|
||||
statuses[name] = "created"
|
||||
columns = ", ".join(col for col, _ in keys)
|
||||
plan = db.execute(f"EXPLAIN QUERY PLAN SELECT {columns} FROM {table} INDEXED BY {name}").fetchall()
|
||||
if not any(f"COVERING INDEX {name}" in row[3] for row in plan):
|
||||
raise RuntimeError(f"Index is not covering: {name}")
|
||||
db.commit()
|
||||
except BaseException:
|
||||
db.rollback()
|
||||
raise
|
||||
return statuses
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", type=Path, required=True, help="path to the agent state.db")
|
||||
parser.add_argument("--lock-file", type=Path, default=None,
|
||||
help="optional lock file to hold exclusively (flock) while indexes are built")
|
||||
parser.add_argument("--confirm-drained", action="store_true",
|
||||
help="assert that no agent turn is running against this database")
|
||||
args = parser.parse_args()
|
||||
result = ensure_read_indexes(args.db, confirmed_drained=args.confirm_drained,
|
||||
lock_file=args.lock_file)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -100,10 +100,11 @@ from urllib.parse import urlparse
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from api.request_logging import emit_request_log
|
||||
from api.auth import check_auth, reset_trusted_auth_request_state
|
||||
from api.auth import check_auth_or_close, reset_trusted_auth_request_state
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import (
|
||||
j,
|
||||
advertise_connection_close,
|
||||
get_profile_cookie,
|
||||
_build_csp_report_only_policy,
|
||||
_CLIENT_DISCONNECT_ERRORS,
|
||||
@@ -332,6 +333,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
extra_frame_src = getattr(self, "_csp_extra_frame_src", None)
|
||||
self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy(extra_connect_src, extra_frame_src))
|
||||
self.send_header("Report-To", self._CSP_REPORT_TO)
|
||||
advertise_connection_close(self) # tell the client when the socket dies
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||
@@ -376,7 +378,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
# Body-pending-aware: a body-bearing GET failing auth would poison reuse (#7550).
|
||||
if not check_auth_or_close(self, parsed): return
|
||||
result = handle_get(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
@@ -401,10 +404,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
_is_csp_report_post = (
|
||||
parsed.path == "/api/csp-report" and self.command == "POST"
|
||||
)
|
||||
if not _is_csp_report_post and not check_auth(self, parsed): return
|
||||
_is_csp_report_post = parsed.path == "/api/csp-report" and self.command == "POST"
|
||||
if not _is_csp_report_post and not check_auth_or_close(self, parsed): return
|
||||
result = route_func(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
|
||||
+10
-1
@@ -3431,8 +3431,17 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
localStorage.setItem('hermes-font-size',fontSize);
|
||||
_applyFontSize(fontSize);
|
||||
if(typeof setLocale==='function'){
|
||||
// #7622 (round 3): the settings payload's `s.language` is
|
||||
// absent (None) for a fresh install, so an explicit non-empty
|
||||
// value is the user's genuine saved choice. The browser
|
||||
// navigator hint is now read via the guarded
|
||||
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
|
||||
// a throwing `navigator` accessor in some embedded webviews
|
||||
// can no longer abort this branch and reset loaded preferences.
|
||||
// The fallback ternary preserves the pre-#7622 boot behaviour
|
||||
// when neither helper is in scope (defence in depth).
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
|
||||
: (s.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
setLocale(_lang);
|
||||
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
const _WEBUI_DISPATCHABLE_AGENT_COMMANDS = new Set([
|
||||
'reload-mcp','reload-skills','codex-runtime','credits',
|
||||
'moa','sessions','resume','pet'
|
||||
]);
|
||||
// ── Slash commands ──────────────────────────────────────────────────────────
|
||||
// Built-in commands intercepted before send(). Each command runs locally
|
||||
// (no round-trip to the agent) and shows feedback via toast or local message.
|
||||
@@ -182,6 +186,31 @@ function executeCommand(text){
|
||||
return {noEcho:!!cmd.noEcho};
|
||||
}
|
||||
|
||||
// Agent-registry slash commands that the WebUI actually dispatches when
|
||||
// submitted. The autocomplete must not advertise anything outside this set:
|
||||
// a non-dispatchable registry command (e.g. /agents) would otherwise fall
|
||||
// through send() to the ordinary chat path and trigger an unintended
|
||||
// model/API request.
|
||||
//
|
||||
// This set holds canonical registry names only. getMatchingCommands() matches
|
||||
// cmd.name (the canonical metadata name), so alias/underscore forms such as
|
||||
// /reload_mcp are covered implicitly: typing one resolves through
|
||||
// getAgentCommandMetadata() at dispatch time and the canonical name is what
|
||||
// gets tested against the dispatcher allowlist (#6951).
|
||||
//
|
||||
// Keep this in sync with _AGENT_COMMANDS_RUN_ON_WEBUI in messages.js and
|
||||
// _ALLOWED_AGENT_COMMANDS in api/commands.py -- the announced list is a
|
||||
// subset of the dispatched list (fail-closed), never a superset.
|
||||
// Plugin-category commands are always dispatchable via the plugin exec
|
||||
// transport. #6951.
|
||||
|
||||
function _isWebuiDispatchableAgentCommand(cmd){
|
||||
const name=String(cmd&&cmd.name||'').trim().toLowerCase();
|
||||
if(_WEBUI_DISPATCHABLE_AGENT_COMMANDS.has(name))return true;
|
||||
// Plugin-registered commands execute via the /api/commands/exec plugin transport.
|
||||
return String(cmd&&cmd.category||'').trim()==='Plugin';
|
||||
}
|
||||
|
||||
function getMatchingCommands(prefix){
|
||||
const q=prefix.toLowerCase();
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
@@ -214,9 +243,14 @@ function getMatchingCommands(prefix){
|
||||
const name=String(cmd&&cmd.name||'').toLowerCase();
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
if(cmd.cli_only&&name!=='pet')continue;
|
||||
// #6951: only announce commands send() actually dispatches -- anything
|
||||
// else would silently fall through to the normal chat path as plain text.
|
||||
if(!_isWebuiDispatchableAgentCommand(cmd))continue;
|
||||
matches.push({
|
||||
name,
|
||||
desc:String(cmd&&cmd.description||'').trim()||'Agent command',
|
||||
// Surface the registry's argument hint on the autocomplete row (#6951).
|
||||
arg:String(cmd&&cmd.args_hint||'').trim()||undefined,
|
||||
source:cmd.category==='Plugin'?'plugin':'agent',
|
||||
});
|
||||
seen.add(name);
|
||||
@@ -957,6 +991,12 @@ async function resumeManualCompressionForSession(sid){
|
||||
// No active compression job or transient server error — not a real failure.
|
||||
// 404: route missed or session gone; 5xx: backend exception during status check.
|
||||
if(e&&(!e.status||e.status===404||e.status>=500)) return;
|
||||
// #7710: a cross-profile refusal now arrives as 409
|
||||
// (``session_profile_mismatch``) where it used to be a 404 that hit the
|
||||
// benign early-return above. It is not a compression failure, so do not
|
||||
// render the error state or locally settle the compression UI.
|
||||
if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function'
|
||||
&&_sessionProfileMismatchFromError(e)) return;
|
||||
if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){
|
||||
const visibleMessages=_manualCompressionVisibleMessages();
|
||||
setCompressionUi({
|
||||
|
||||
+88
-20
@@ -1770,7 +1770,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO mode OFF',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO mode active — click to disable',
|
||||
approval_skip_all: '⚡ Skip all this session',
|
||||
approval_skip_all: 'Skip all this session',
|
||||
approval_skip_all_title: 'Skip all approval prompts for this session',
|
||||
// composer action tooltips
|
||||
composer_send: 'Send message',
|
||||
@@ -3572,7 +3572,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'Modalità YOLO OFF',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'Modalità YOLO attiva — clicca per disabilitare',
|
||||
approval_skip_all: '⚡ Salta tutto questa sessione',
|
||||
approval_skip_all: 'Salta tutto questa sessione',
|
||||
approval_skip_all_title: 'Salta tutte le richieste di approvazione per questa sessione',
|
||||
// composer action tooltips
|
||||
composer_send: 'Invia messaggio',
|
||||
@@ -5382,7 +5382,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO モード OFF',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO モード有効 — クリックで無効化',
|
||||
approval_skip_all: '⚡ このセッションはすべてスキップ',
|
||||
approval_skip_all: 'このセッションはすべてスキップ',
|
||||
approval_skip_all_title: 'このセッションのすべての承認プロンプトをスキップ',
|
||||
// composer action tooltips
|
||||
composer_send: 'メッセージを送信',
|
||||
@@ -7063,7 +7063,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO режим ВЫКЛ',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO режим активен — нажмите для отключения',
|
||||
approval_skip_all: '⚡ Пропустить все в этом сеансе',
|
||||
approval_skip_all: 'Пропустить все в этом сеансе',
|
||||
approval_skip_all_title: 'Пропустить все запросы подтверждения в этом сеансе',
|
||||
// composer action tooltips
|
||||
composer_send: 'Отправить сообщение',
|
||||
@@ -8830,7 +8830,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'Modo YOLO desactivado',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'Modo YOLO activo — haz clic para desactivar',
|
||||
approval_skip_all: '⚡ Saltar todo en esta sesión',
|
||||
approval_skip_all: 'Saltar todo en esta sesión',
|
||||
approval_skip_all_title: 'Saltar todas las solicitudes de aprobación en esta sesión',
|
||||
// composer action tooltips
|
||||
composer_send: 'Enviar mensaje',
|
||||
@@ -10236,7 +10236,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO-Modus AUS',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO-Modus aktiv — Klicken zum Deaktivieren',
|
||||
approval_skip_all: '⚡ Alle in dieser Sitzung überspringen',
|
||||
approval_skip_all: 'Alle in dieser Sitzung überspringen',
|
||||
approval_skip_all_title: 'Alle Genehmigungsanfragen in dieser Sitzung überspringen',
|
||||
// composer action tooltips
|
||||
composer_send: 'Nachricht senden',
|
||||
@@ -12312,7 +12312,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO 模式已关闭',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO 模式激活 — 点击关闭',
|
||||
approval_skip_all: '⚡ 本次会话全部跳过',
|
||||
approval_skip_all: '本次会话全部跳过',
|
||||
approval_skip_all_title: '跳过本次会话的所有审批提示',
|
||||
// composer action tooltips
|
||||
composer_send: '发送消息',
|
||||
@@ -14208,7 +14208,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO 模式已關閉',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO 模式已啟用,點選即可關閉',
|
||||
approval_skip_all: '⚡ 本次工作階段全部跳過',
|
||||
approval_skip_all: '本次工作階段全部跳過',
|
||||
approval_skip_all_title: '略過本次工作階段的所有核准提示',
|
||||
// composer action tooltips
|
||||
composer_send: '傳送訊息',
|
||||
@@ -17540,7 +17540,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO 모드 꺼짐',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO 모드 활성 — 클릭하여 비활성화',
|
||||
approval_skip_all: '⚡ 이 세션에서 모두 건너뛰기',
|
||||
approval_skip_all: '이 세션에서 모두 건너뛰기',
|
||||
approval_skip_all_title: '이 세션의 모든 승인 요청 건너뛰기',
|
||||
// composer action tooltips
|
||||
composer_send: '메시지 보내기',
|
||||
@@ -19412,7 +19412,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'Mode YOLO désactivé',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'Mode YOLO actif — cliquez pour désactiver',
|
||||
approval_skip_all: '⚡ Ignorer tout cette session',
|
||||
approval_skip_all: 'Ignorer tout cette session',
|
||||
approval_skip_all_title: 'Ignorer toutes les demandes d\'approbation pour cette session',
|
||||
composer_send: 'Envoyer le message',
|
||||
composer_queue: 'Mettre en file d\'attente',
|
||||
@@ -19540,7 +19540,7 @@ const LOCALES = {
|
||||
add_failed: 'Přidání selhalo: ',
|
||||
agent_soul: 'Duch agenta',
|
||||
all_uploads_failed: (n) => `Všech ${n} nahrávání selhalo`,
|
||||
approval_skip_all: '⚡ Přeskočit vše pro tuto relaci',
|
||||
approval_skip_all: 'Přeskočit vše pro tuto relaci',
|
||||
approval_skip_all_title: 'Přeskočit všechny výzvy ke schválení pro tuto relaci',
|
||||
archive_extracted: (n, c) => `Rozbaleno ${n} souborů z ${c} archivů`,
|
||||
auth_ack_save_failed: 'Nepodařilo se aktualizovat potvrzení: ',
|
||||
@@ -22894,7 +22894,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'YOLO modu KAPALI',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'YOLO modu etkin — devre dışı bırakmak için tıklayın',
|
||||
approval_skip_all: '⚡ Tüm bu oturumu atla',
|
||||
approval_skip_all: 'Tüm bu oturumu atla',
|
||||
approval_skip_all_title: 'Bu oturuma ilişkin tüm onay istemlerini atla',
|
||||
// composer action tooltips
|
||||
composer_send: 'Mesaj gönder',
|
||||
@@ -24778,7 +24778,7 @@ const LOCALES = {
|
||||
yolo_disabled: 'Tryb YOLO WYŁĄCZONY',
|
||||
yolo_pill_label: 'YOLO',
|
||||
yolo_pill_title_active: 'Tryb YOLO aktywny — kliknij, aby wyłączyć',
|
||||
approval_skip_all: '⚡ Pomiń wszystkie w tej sesji',
|
||||
approval_skip_all: 'Pomiń wszystkie w tej sesji',
|
||||
approval_skip_all_title: 'Pomiń wszystkie monity o aprobatę w tej sesji',
|
||||
// composer action tooltips
|
||||
composer_send: 'Wyślij wiadomość',
|
||||
@@ -26411,7 +26411,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: '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',
|
||||
checkpoint_empty: 'Không tìm thấy checkpoint nào cho workspace này.',
|
||||
@@ -26985,13 +26985,66 @@ function resolveLocale(lang) {
|
||||
* Resolve locale with precedence:
|
||||
* 1) primary (typically server setting)
|
||||
* 2) fallback (typically localStorage)
|
||||
* 3) English
|
||||
* @param {string} primary
|
||||
* @param {string} fallback
|
||||
* 3) fallback2 (typically browser navigator hint — first-visit only)
|
||||
* 4) 'en' (final fallback)
|
||||
*
|
||||
* Resolve a language preference to a known LOCALES key, applying a strict
|
||||
* `primary -> fallback -> fallback2 -> 'en'` precedence chain.
|
||||
*
|
||||
* #7622 (round 3): the server no longer injects `"language": "en"` as a
|
||||
* schema default — `load_settings()` reports `None` (the key is absent
|
||||
* from the response) until the user explicitly picks a locale in the
|
||||
* Settings modal. This lets the resolver use `primary` directly
|
||||
* without a `primary === 'en'` special case:
|
||||
* - an explicit `primary` (the server's stored value) always wins
|
||||
* - a `None` / empty `primary` falls through to `fallback` (localStorage)
|
||||
* - `fallback2` (the browser navigator hint) only fires when neither
|
||||
* server nor localStorage has a preference
|
||||
* - `'en'` is the final safety net
|
||||
*
|
||||
* Side benefit: a user who picked English on purpose (their settings
|
||||
* file contains `"language": "en"`) is no longer overridden by a
|
||||
* non-English browser locale on hydration, because the round-2
|
||||
* `primary === 'en'` skip has been removed.
|
||||
*
|
||||
* @param {string|null|undefined} primary server-stored language
|
||||
* @param {string|null|undefined} fallback localStorage `hermes-lang`
|
||||
* @param {string|null|undefined} fallback2 browser navigator hint
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolvePreferredLocale(primary, fallback) {
|
||||
return resolveLocale(primary) || resolveLocale(fallback) || 'en';
|
||||
function resolvePreferredLocale(primary, fallback, fallback2) {
|
||||
return resolveLocale(primary)
|
||||
|| resolveLocale(fallback)
|
||||
|| resolveLocale(fallback2)
|
||||
|| 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the browser's preferred language for first-visit locale
|
||||
* selection. Guarded against `navigator` access exceptions: some
|
||||
* embedded webviews (Tauri desktop shell, kiosk modes) report a
|
||||
* `navigator` object but throw on `.languages` / `.language` reads.
|
||||
* Callers (loadLocale, boot.js, panels.js) must use this helper rather
|
||||
* than reading `navigator` directly so a single throw does not abort
|
||||
* the boot or settings-panel hydration.
|
||||
*
|
||||
* @returns {string|null} the first entry of `navigator.languages`, or
|
||||
* `navigator.language`, or `null` when neither is reachable.
|
||||
*/
|
||||
function _detectBrowserLanguageHint() {
|
||||
try {
|
||||
const nav = (typeof navigator !== 'undefined') ? navigator : null;
|
||||
if (!nav) return null;
|
||||
if (Array.isArray(nav.languages) && nav.languages.length) {
|
||||
return nav.languages[0] || null;
|
||||
}
|
||||
if (typeof nav.language === 'string' && nav.language) {
|
||||
return nav.language;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27029,11 +27082,26 @@ function setLocale(lang) {
|
||||
/**
|
||||
* Load locale from localStorage (called once at boot, before DOMContentLoaded).
|
||||
* Server-persisted preference is applied later in loadSettingsPanel().
|
||||
*
|
||||
* #7622: on the FIRST visit (no stored preference), fall back to the
|
||||
* browser's preferred language (`navigator.languages[0]` /
|
||||
* `navigator.language`) so non-English speakers don't have to dig into
|
||||
* Settings after every fresh install. This is strictly a first-visit
|
||||
* hint — the moment the user picks a language in Settings (or the
|
||||
* server-side preference is applied via `loadSettingsPanel`), the
|
||||
* stored value takes over and the browser hint is never consulted
|
||||
* again for that browser profile.
|
||||
*/
|
||||
function loadLocale() {
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem('hermes-lang'); } catch (_) {}
|
||||
setLocale(resolvePreferredLocale(null, stored));
|
||||
// Only consult the browser hint when no stored preference exists.
|
||||
// The settings panel and `loadSettingsPanel()` will overwrite this
|
||||
// hint on their first call, so explicit user / server preferences
|
||||
// always win. #7622 (round 3): the browser-hint read is delegated
|
||||
// to `_detectBrowserLanguageHint()` so a `navigator` access throw
|
||||
// (some embedded webviews) can no longer abort loadLocale().
|
||||
setLocale(resolvePreferredLocale(stored, _detectBrowserLanguageHint()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-1
@@ -7,7 +7,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.getElementById('pw');
|
||||
var passkeyBtn = document.getElementById('passkey-login');
|
||||
|
||||
if (!form || !input) return;
|
||||
// #7056: the password input is absent on a passwordless deployment (OIDC-only,
|
||||
// or passkey-only). The form itself still renders and carries the i18n data
|
||||
// attributes, and the passkey button below must still be wired up, so only the
|
||||
// form is required here — every password-specific path is guarded individually.
|
||||
if (!form) return;
|
||||
|
||||
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
|
||||
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
|
||||
@@ -59,6 +63,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
// No password input on a passwordless deployment: nothing to submit.
|
||||
if (!input) return;
|
||||
var pw = input.value;
|
||||
hideErr();
|
||||
try {
|
||||
@@ -147,12 +153,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
passkeyBtn.addEventListener('click', doPasskeyLogin);
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doLogin(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On page load, probe the server so we can distinguish "can't reach server"
|
||||
// (Tailscale off, wrong network) from "session expired / need to log in".
|
||||
|
||||
+163
-13
@@ -1,3 +1,13 @@
|
||||
const _AGENT_COMMAND_ALIASES = {
|
||||
'reload_mcp': 'reload-mcp',
|
||||
'reload_skills': 'reload-skills',
|
||||
'codex_runtime': 'codex-runtime',
|
||||
'credits': 'credits'
|
||||
};
|
||||
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set([
|
||||
'reload-mcp','reload-skills','codex-runtime','credits',
|
||||
'reload_mcp','reload_skills','codex_runtime','credits'
|
||||
]);
|
||||
function _markSessionViewed(sid, messageCount) {
|
||||
if(typeof _setSessionViewedCount!=='function' || !sid) return;
|
||||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
@@ -1184,7 +1194,6 @@ const _sessionTitleProvisionalBySid = new Map();
|
||||
// their canonical command is registered on the backend (for example
|
||||
// /reload-mcp). Keep this intentionally narrow and include underscore variants
|
||||
// observed by users so typing either form still routes through executeAgentCommand.
|
||||
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'reload-skills', 'reload_skills', 'codex-runtime', 'codex_runtime', 'credits']);
|
||||
|
||||
function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
|
||||
if(!S||!S.busy||compressionRunning) return false;
|
||||
@@ -1287,6 +1296,31 @@ function applySessionTitleUpdate(sid, titleText, options={}){
|
||||
// BEFORE slash rewrites (/moa, bundles) mutate the payload and BEFORE
|
||||
// uploadPendingFiles() drains S.pendingFiles — so we restore what the user
|
||||
// actually typed, not the transformed send payload.
|
||||
async function _recoverCompressedSend(error,sid,draftText,filesSnapshot,clearPromise){
|
||||
let payload;
|
||||
try{ payload=JSON.parse(error&&error.body||'{}'); }catch(_){ return false; }
|
||||
const target=payload&&payload.continuation_session_id;
|
||||
if(!error||error.status!==409||!payload||payload.code!=='session_rotated'||typeof target!=='string'||!target||target===sid) return false;
|
||||
// A failed POST has not admitted a turn. Never resend automatically: the
|
||||
// continuation may already be busy, and attachments must remain a draft.
|
||||
if(!S.session||S.session.session_id!==sid) return false;
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
if(typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid);
|
||||
stopApprovalPolling();stopClarifyPolling();removeThinking();setBusy(false);
|
||||
try{
|
||||
await loadSession(target);
|
||||
if(!S.session||S.session.session_id===sid) return false;
|
||||
// loadSession can lose its navigation race to another tab selection. Never
|
||||
// place the rejected message into that unrelated session's composer.
|
||||
_restoreComposerDraftAfterFailedSend(draftText,filesSnapshot,target,clearPromise);
|
||||
if(S.session.session_id!==target) return true;
|
||||
setComposerStatus('Session resumed. Your message is preserved; send it when ready.');
|
||||
showToast('Session resumed after compression. Your draft is preserved.',4000);
|
||||
return true;
|
||||
}catch(_){ return false; }
|
||||
}
|
||||
|
||||
function _restoreComposerDraftAfterFailedSend(draftText, filesSnapshot, sid, clearPromise){
|
||||
const restore=String(draftText||'');
|
||||
const files=Array.isArray(filesSnapshot)?filesSnapshot.filter(Boolean):[];
|
||||
@@ -1334,7 +1368,7 @@ function _restoreComposerDraftAfterFailedSend(draftText, filesSnapshot, sid, cle
|
||||
} else if(!restoredVisible){
|
||||
// Background failure (sid was never the visible session): no live
|
||||
// composer to read, so persist the captured snapshot — it's the only copy.
|
||||
_saveComposerDraftNow(sid, restore, []);
|
||||
_saveComposerDraftNow(sid, restore, files);
|
||||
}
|
||||
// else: restored the visible composer, then the user switched away — the
|
||||
// session-switch save path already saved sid's composer; skip stale write.
|
||||
@@ -1409,14 +1443,16 @@ async function send(){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
// Busy-control slash commands must be intercepted HERE, before the
|
||||
// defaultMessageMode routing block, so the user can always type /steer, /interrupt,
|
||||
// /queue, /terminal, /goal, or /yolo while the agent is running and have
|
||||
// /queue, /terminal, /goal, /yolo, or /stop while the agent is running and have
|
||||
// them execute immediately.
|
||||
// Without this intercept they fall through to the queue and execute after
|
||||
// the current turn ends — by which point there is no active stream and
|
||||
// cmdSteer / cmdInterrupt say "No active task to stop."
|
||||
// /stop must cancel the active run immediately instead of being steered
|
||||
// or queued as the literal text "/stop" (#6951).
|
||||
if(text.startsWith('/')&&!literalSlash){
|
||||
const _pc=typeof parseCommand==='function'&&parseCommand(text);
|
||||
if(_pc&&['steer','interrupt','queue','terminal','goal','yolo'].includes(_pc.name)){
|
||||
if(_pc&&['steer','interrupt','queue','terminal','goal','yolo','stop'].includes(_pc.name)){
|
||||
const _bc=COMMANDS.find(c=>c.name===_pc.name);
|
||||
if(_bc){
|
||||
$('msg').value='';autoResize();
|
||||
@@ -1839,6 +1875,7 @@ async function send(){
|
||||
if(typeof renderSessionList==='function') void renderSessionList();
|
||||
return;
|
||||
}
|
||||
if(await _recoverCompressedSend(e,activeSid,_failedSendDraftText,_failedSendFilesSnapshot,_composerDraftClearPromise)) return;
|
||||
const conflictActiveStream=/session already has an active stream/i.test(errMsg);
|
||||
if(conflictActiveStream){
|
||||
delete INFLIGHT[activeSid];
|
||||
@@ -2052,6 +2089,7 @@ function closeLiveStream(sessionId, streamId, source){
|
||||
if(!live) return;
|
||||
if(streamId&&live.streamId!==streamId) return;
|
||||
if(source&&live.source!==source) return;
|
||||
if(typeof live.cancelIdleRecovery==='function') live.cancelIdleRecovery();
|
||||
// Snapshot the current live-turn DOM BEFORE tearing the stream down. The
|
||||
// per-event snapshot (snapshotLiveTurn) only fires on content/tool_complete
|
||||
// SSE events, so switching away during a quiet window (mid tool-exec, silent
|
||||
@@ -4808,7 +4846,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function _smdMediaTailFlushEntry(entry){
|
||||
const chunk=_smdMediaTailEntryChunk(entry);
|
||||
if(!chunk) return;
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
|
||||
// #7680 re-gate (9/22): strip backtick wrappers so the bare-token
|
||||
// match below sees a plain ``MEDIA:path`` and the bare class
|
||||
// (no backtick in the exclusion set) captures the full filename
|
||||
// even when the path itself contains a backtick.
|
||||
const normalized = String(chunk).replace(/`MEDIA:([^`\s]+)`/g, 'MEDIA:$1');
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(normalized);
|
||||
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
|
||||
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
|
||||
}
|
||||
@@ -4856,23 +4899,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Prose runs go through the owning text writer. MEDIA tokens go through
|
||||
// the single-token DOMParser helper only after a delimiter or
|
||||
// reliable filename suffix proves the ref is complete.
|
||||
// #7680 re-gate (9/22): strip backtick wrappers first so the bare
|
||||
// class (no backtick in the exclusion set) captures the full
|
||||
// filename even when the path itself contains a backtick.
|
||||
// The pre-pass replaces `` `MEDIA:path` `` with ``MEDIA:path``
|
||||
// so the wrapped form is consumed before the bare scan.
|
||||
const normalized = combined.replace(/`MEDIA:([^`\s]+)`/g, 'MEDIA:$1');
|
||||
const re=/MEDIA:([^\s\)\]]+)/g;
|
||||
let last=0, m;
|
||||
let unmatchedTail=null;
|
||||
while((m=re.exec(combined))){
|
||||
while((m=re.exec(normalized))){
|
||||
const matchEnd = m.index + m[0].length;
|
||||
if(m.index>last){
|
||||
const slice = combined.slice(last, m.index);
|
||||
const slice = normalized.slice(last, m.index);
|
||||
writeCurrent(slice);
|
||||
}
|
||||
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
|
||||
const candidate = combined.slice(m.index);
|
||||
if(matchEnd===normalized.length && !_smdMediaRefHasReliableBoundary(m[1])){
|
||||
const candidate = normalized.slice(m.index);
|
||||
if(candidate.length < _MEDIA_TAIL_MAX){
|
||||
unmatchedTail = candidate;
|
||||
} else {
|
||||
writeCurrent(candidate);
|
||||
}
|
||||
last = combined.length;
|
||||
last = normalized.length;
|
||||
break;
|
||||
}
|
||||
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
|
||||
@@ -4880,7 +4929,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
// Tail buffer — hold trailing bytes that look like an unterminated
|
||||
// MEDIA prefix; flush any prose before the partial MEDIA suffix.
|
||||
const rest = combined.slice(last);
|
||||
const rest = normalized.slice(last);
|
||||
if(rest){
|
||||
const tailMatch = /MEDIA:[^\s\)\]]*$/.exec(rest);
|
||||
const prefixTail = tailMatch ? '' : _smdMediaPrefixTail(rest);
|
||||
@@ -5708,12 +5757,82 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return true;
|
||||
}
|
||||
|
||||
function _bindSidebarIdleRecovery(live){
|
||||
let pending=null;
|
||||
live.cancelIdleRecovery=()=>{
|
||||
if(pending&&pending.timer) clearTimeout(pending.timer);
|
||||
pending=null;
|
||||
};
|
||||
live.recoverFromSidebarIdle=()=>{
|
||||
if(pending||_streamFinalized||_terminalStateReached||_pendingStreamEndRecovery) return;
|
||||
const request={inflight:INFLIGHT[activeSid],timer:null};
|
||||
const isCurrent=()=>pending===request&&!_streamFinalized&&!_terminalStateReached&&
|
||||
LIVE_STREAMS[activeSid]===live&&live.source.readyState===1&&
|
||||
S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId&&
|
||||
INFLIGHT[activeSid]===request.inflight&&
|
||||
!(typeof _sendInProgress!=='undefined'&&_sendInProgress&&activeSid===_sendInProgressSid);
|
||||
pending=request;
|
||||
// Give the independently delivered terminal frame a short handoff window,
|
||||
// then use canonical session recovery even if the transport stays OPEN.
|
||||
// One ticket spans both timer and request; list refreshes cannot extend it.
|
||||
request.timer=setTimeout(async()=>{
|
||||
request.timer=null;
|
||||
if(!isCurrent()){
|
||||
if(pending===request) live.cancelIdleRecovery();
|
||||
return;
|
||||
}
|
||||
try{
|
||||
let runtimeStatus=null;
|
||||
try{
|
||||
runtimeStatus=await api(
|
||||
`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`,
|
||||
{timeoutMs:8000,retries:0,timeoutToast:false}
|
||||
);
|
||||
}catch(_){
|
||||
// Runtime ownership is authoritative here. If the probe itself
|
||||
// fails, do not infer completion from already-cleared session
|
||||
// fields; surface the existing interrupted-stream path instead.
|
||||
if(isCurrent()) _handleStreamError(live.source);
|
||||
return;
|
||||
}
|
||||
if(!isCurrent()) return;
|
||||
if(!runtimeStatus||typeof runtimeStatus.active!=='boolean'){
|
||||
_handleStreamError(live.source);
|
||||
return;
|
||||
}
|
||||
// Gateway success writeback clears persisted active/pending fields
|
||||
// before post-turn goal evaluation and terminal event emission.
|
||||
// STREAMS-backed status therefore owns this decision: an exact active
|
||||
// runtime must keep its OPEN browser handoff even if the sidebar row
|
||||
// already looks idle.
|
||||
if(runtimeStatus.active) return;
|
||||
const status=await _restoreSettledSession(live.source,{
|
||||
status:true,
|
||||
isCurrent,
|
||||
requestOptions:{timeoutMs:8000,retries:0,timeoutToast:false},
|
||||
preserveVisibleOnShorterTerminalSnapshot:true,
|
||||
});
|
||||
// A stale sidebar response is not permission to terminate a worker
|
||||
// which the authoritative session snapshot still reports as active.
|
||||
if(isCurrent()&&status!=='restored'&&status!=='active') _handleStreamError(live.source);
|
||||
}finally{
|
||||
if(pending===request) live.cancelIdleRecovery();
|
||||
}
|
||||
},1500);
|
||||
};
|
||||
for(const event of ['done','cancel','apperror','stream_end','error']){
|
||||
live.source.addEventListener(event,live.cancelIdleRecovery);
|
||||
}
|
||||
}
|
||||
|
||||
function _wireSSE(source){
|
||||
const existingLive=LIVE_STREAMS[activeSid];
|
||||
if(existingLive&&existingLive.source&&existingLive.source!==source){
|
||||
if(typeof existingLive.cancelIdleRecovery==='function') existingLive.cancelIdleRecovery();
|
||||
try{if(existingLive.source.readyState!==2)existingLive.source.close();}catch(_){ }
|
||||
}
|
||||
LIVE_STREAMS[activeSid]={streamId,source};
|
||||
_bindSidebarIdleRecovery(LIVE_STREAMS[activeSid]);
|
||||
|
||||
// Note on #631 Bug B: the original PR description stated the server
|
||||
// "replays buffered token events" on reconnect, and proposed resetting
|
||||
@@ -6987,13 +7106,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
async function _restoreSettledSession(source, options=null){
|
||||
const returnStatus=!!(options&&options.status);
|
||||
const isCurrent=options&&typeof options.isCurrent==='function'?options.isCurrent:null;
|
||||
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||
const preserveVisibleOnShorterTerminalSnapshot=!!(options&&options.preserveVisibleOnShorterTerminalSnapshot);
|
||||
if(_isActiveSession() && S.activeStreamId!==streamId){
|
||||
_closeSource(source);
|
||||
return returnStatus?'stale':false;
|
||||
}
|
||||
try{
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`,options&&options.requestOptions||{});
|
||||
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
|
||||
// we were awaiting the network roundtrip, bail out — done already settled.
|
||||
if(_streamFinalized) return returnStatus?'restored':true;
|
||||
@@ -8091,7 +8213,22 @@ function _startHiddenActiveStreamPoll(sid) {
|
||||
if (S.activeStreamId) return; // already rendering; wait it out
|
||||
try {
|
||||
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(r => {
|
||||
// #7299: 404 Not Found / 410 Gone are TERMINAL for this
|
||||
// session-owned poll. The session has been deleted or no
|
||||
// longer exists in the active state directory, so further
|
||||
// polls are guaranteed to fail. Stop the poll immediately
|
||||
// to avoid the infinite 404 loop on stale background tabs
|
||||
// (one tab could fire ~10 such requests per minute; multiple
|
||||
// tabs multiply the noise). Transient failures (5xx, rate
|
||||
// limit, network error) keep polling — only the missing
|
||||
// session itself is terminal.
|
||||
if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) {
|
||||
_stopHiddenActiveStreamPoll();
|
||||
return null;
|
||||
}
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(d => {
|
||||
if (!d || _sessionStreamHiddenPollSid !== sid) return;
|
||||
const streamId = d.active_stream_id;
|
||||
@@ -8951,6 +9088,19 @@ async function respondClarify(response) {
|
||||
// not tear B down on A's late 409. The SSE/poll path will re-render the
|
||||
// next prompt's card from scratch via ``showClarifyCard`` either way.
|
||||
if (e && e.status === 409) {
|
||||
// #7710: a cross-profile refusal now also arrives as 409
|
||||
// (``session_profile_mismatch``). The prompt is NOT expired — the write
|
||||
// was refused because the session belongs to another profile. Treating
|
||||
// it as expired would hide a live clarification card and mislabel the
|
||||
// cause, so leave the card standing and report the real reason.
|
||||
if (typeof _sessionProfileMismatchFromError === 'function'
|
||||
&& _sessionProfileMismatchFromError(e)) {
|
||||
_clarifySetControlsDisabled(false, false);
|
||||
if (typeof setStatus === "function") {
|
||||
setStatus("Clarify: session belongs to a different profile");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_clarifyId === clarifyId) {
|
||||
// Same card still showing — dismiss it and rescue the typed draft.
|
||||
// Order matters: ``_stashClarifyDraft`` (called from
|
||||
|
||||
+10
-1
@@ -9300,8 +9300,17 @@ async function loadSettingsPanel(){
|
||||
_setHiddenTabs(hiddenTabs);
|
||||
_applyTabVisibility(hiddenTabs);
|
||||
_renderTabVisibilityChips();
|
||||
// #7622 (round 3): the settings payload's `settings.language` is
|
||||
// absent (None) for a fresh install, so an explicit non-empty
|
||||
// value is the user's genuine saved choice. The browser
|
||||
// navigator hint is now read via the guarded
|
||||
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
|
||||
// a throwing `navigator` accessor can no longer abort settings
|
||||
// hydration before model, provider, plugin and extension sections
|
||||
// are populated. The fallback ternary preserves the pre-#7622
|
||||
// settings-modal behaviour when neither helper is in scope.
|
||||
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
|
||||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
// Keep settings modal and current page strings in sync with the resolved locale.
|
||||
if(typeof setLocale==='function'){
|
||||
|
||||
+26
-1
@@ -852,6 +852,14 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
|
||||
if (!serverRow) return false;
|
||||
if (!_isServerIdleSessionRow(serverRow)) return false;
|
||||
// Sidebar idle metadata can beat the terminal frame on the independent chat
|
||||
// SSE. Let its exact OPEN transport finish the Anchor handoff; orphaned or
|
||||
// disconnected streams still use the existing idle recovery below.
|
||||
if (_hasOwnedOpenLiveStream(sid)) {
|
||||
const live=LIVE_STREAMS[sid];
|
||||
if(typeof live.recoverFromSidebarIdle==='function') live.recoverFromSidebarIdle();
|
||||
return false;
|
||||
}
|
||||
let changed=false;
|
||||
if (S.busy) { S.busy=false; changed=true; }
|
||||
if (S.activeStreamId) { S.activeStreamId=null; changed=true; }
|
||||
@@ -940,6 +948,8 @@ function _purgeStaleInflightEntries() {
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
|
||||
continue;
|
||||
}
|
||||
// The sidebar render must not purge what the idle reconciler preserved.
|
||||
if (_hasOwnedOpenLiveStream(sid)) continue;
|
||||
if (!sessionsById.has(sid)) {
|
||||
const knownSource = sourceById ? sourceById.get(sid) : null;
|
||||
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) {
|
||||
@@ -961,6 +971,14 @@ function _purgeStaleInflightEntries() {
|
||||
}
|
||||
}
|
||||
|
||||
function _hasOwnedOpenLiveStream(sid) {
|
||||
if (typeof S === 'undefined' || !S || !S.session || S.session.session_id !== sid || !S.activeStreamId) return false;
|
||||
const live = typeof LIVE_STREAMS === 'object' && LIVE_STREAMS ? LIVE_STREAMS[sid] : null;
|
||||
// readyState 1 is EventSource.OPEN. A cached stream ID or busy flag alone
|
||||
// is not ownership and must never disable recovery for a stuck indicator.
|
||||
return Boolean(live && live.streamId === S.activeStreamId && live.source && live.source.readyState === 1);
|
||||
}
|
||||
|
||||
function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) {
|
||||
const resolvedSid = sid || (s && s.session_id);
|
||||
if (!resolvedSid) return;
|
||||
@@ -2169,6 +2187,12 @@ async function loadSession(sid){
|
||||
S.activeStreamId=activeStreamId;
|
||||
const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||||
const replayPersistedLiveToolCards=(opts)=>{
|
||||
// The journal-backed Anchor scene is authoritative through its resume
|
||||
// cursor; newer rows arrive through the reattached SSE stream. Replaying
|
||||
// the older INFLIGHT tool cache after a successful scene restore would
|
||||
// redraw all N rows N times. Keep the #3707 replay only for legacy HTML
|
||||
// restoration or a failed/unavailable scene render.
|
||||
if(restoredAnchorScene) return;
|
||||
const liveToolCalls=Array.isArray(S.toolCalls)
|
||||
? S.toolCalls
|
||||
: (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]);
|
||||
@@ -5272,7 +5296,8 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){
|
||||
function _dropStaleOptimisticSessionRow(sid){
|
||||
if(!sid) return;
|
||||
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
|
||||
if(INFLIGHT&&INFLIGHT[sid]){
|
||||
// Retiring sidebar optimism must not retire the independent chat owner.
|
||||
if(INFLIGHT&&INFLIGHT[sid]&&!_hasOwnedOpenLiveStream(sid)){
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
}
|
||||
|
||||
+247
-6
@@ -508,6 +508,16 @@ async function startCompressionRecovery(btn){
|
||||
const composer=$('msg');
|
||||
if(composer&&typeof composer.focus==='function') composer.focus();
|
||||
}catch(e){
|
||||
// #7710: a cross-profile refusal now also arrives as 409
|
||||
// (``session_profile_mismatch``). That is NOT a stale recovery action —
|
||||
// the card is still valid, the request was simply refused because the
|
||||
// session belongs to another profile. Retiring it would hide a live card
|
||||
// and show a false "conversation already moved on" note.
|
||||
if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function'
|
||||
&&_sessionProfileMismatchFromError(e)){
|
||||
if(typeof setStatus==='function') setStatus('Session belongs to a different profile');
|
||||
return;
|
||||
}
|
||||
// A 409 means this session no longer has an active recovery action (the
|
||||
// session already moved on — e.g. a substantive prompt cleared it). The
|
||||
// persisted card in the transcript is stale, so retire it and show a neutral
|
||||
@@ -3939,6 +3949,27 @@ function _isEquivalentConfiguredModelEntry(modelId,badge,entries){
|
||||
// providers (@custom:name:model) without collapsing matching model IDs from
|
||||
// different providers.
|
||||
const rawId=String(modelId||'');
|
||||
// `<provider>/<model>` is another routing spelling of `<model>`, so its badge
|
||||
// key must not become a second picker row. configured_model_badges holds every
|
||||
// spelling of a configured model and renderModelDropdown() synthesises a row
|
||||
// for each key this predicate does not recognise. The provider-qualified
|
||||
// spelling was missed because _normalizeConfiguredModelKey() strips only one
|
||||
// leading slash segment (#3360 keeps `vendor_a/x` and `vendor_b/y/x` distinct),
|
||||
// so `acme/example-model` and `custom/acme/example-model` normalise to
|
||||
// different keys and the picker lists one model twice.
|
||||
// Match it the way the `@provider:` rule below does: the badge declares a
|
||||
// provider, the key starts with that provider's `<provider>/` prefix, and an
|
||||
// existing row from the same provider normalises equal to the remainder. Two
|
||||
// different models never satisfy the last clause, so this can only drop a
|
||||
// duplicate of a row the catalog already produced.
|
||||
const slashPrefix=provider?`${provider}/`:'';
|
||||
if(slashPrefix&&rawId.toLowerCase().startsWith(slashPrefix)){
|
||||
const slashRoutedId=rawId.slice(slashPrefix.length);
|
||||
if(slashRoutedId&&(entries||[]).some(entry=>
|
||||
String(entry.providerId||'').toLowerCase()===provider
|
||||
&&_normalizeConfiguredModelKey(entry.value)===_normalizeConfiguredModelKey(slashRoutedId)
|
||||
)) return true;
|
||||
}
|
||||
const prefix=provider?`@${provider}:`:'';
|
||||
if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;
|
||||
const routedId=rawId.slice(prefix.length);
|
||||
@@ -7692,6 +7723,16 @@ function renderMd(raw){
|
||||
// generated images) and replace them with inline <img> or download links.
|
||||
// Stashed so the path/URL is never processed as markdown.
|
||||
const media_stash=[];
|
||||
// #7680 re-gate (9/22): two-pass scan.
|
||||
// 1. `` `MEDIA:path` `` (backtick-wrapped, inline-code form) → strip
|
||||
// the wrapping backticks so the bare-token pass below sees a
|
||||
// plain ``MEDIA:path`` and the closing backtick is not consumed
|
||||
// as part of the path.
|
||||
// 2. ``MEDIA:[^\s\)\]]+`` (bare, no backtick in the exclusion
|
||||
// class) so a filename that legally contains a backtick
|
||||
// (``report`final.png``) is captured in full instead of being
|
||||
// truncated at the first backtick.
|
||||
s=s.replace(/`MEDIA:([^`\s]+)`/g,'MEDIA:$1');
|
||||
s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{
|
||||
media_stash.push(raw_ref);
|
||||
return '\x00D'+(media_stash.length-1)+'\x00';
|
||||
@@ -9139,6 +9180,12 @@ function _stripForTTS(text){
|
||||
// Strip links, keep text
|
||||
text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
|
||||
// Replace MEDIA: paths with a simple label
|
||||
// #7680 re-gate (9/22): TTS does not need the backtick-aware
|
||||
// terminator. Inline code was already stripped at the top of this
|
||||
// function (`:9137`), so a bare ``MEDIA:[^\s]+`` is correct and
|
||||
// round-trips the original filename including a backtick in the
|
||||
// path — the only thing the TTS ever does with the captured
|
||||
// substring is throw it away.
|
||||
text=text.replace(/MEDIA:[^\s]+/g,'a file');
|
||||
// Strip emoji and emoticons
|
||||
text=text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}]/gu,'');
|
||||
@@ -9918,6 +9965,99 @@ function _liveAssistantSegmentTextLength(seg){
|
||||
return String(body.textContent||'').trim().length;
|
||||
}
|
||||
|
||||
// ── #6948 follow-up: settled-transcript ownership of a live-turn node ──────
|
||||
// (duplicate assistant answer; upstream symptom report #2051)
|
||||
//
|
||||
// Both live-turn insertion paths — the renderMessages() re-attach and
|
||||
// restoreLiveTurnHtmlForSession() — have one branch that ADDS a turn the
|
||||
// settled rebuild did not produce. The assistant row is persisted a few ms
|
||||
// BEFORE the stream's terminal event clears S.activeStreamId, so a dead live
|
||||
// node left behind by an INFLIGHT entry that outlived its stream is re-attached
|
||||
// on top of the settled transcript: the same answer twice, self-sustaining
|
||||
// across later renders (the node keeps id=liveAssistantTurn and is re-preserved
|
||||
// by the #6948 guard), healed only by a reload.
|
||||
//
|
||||
// Dropping a live node must be an OWNERSHIP proof, never "the rendered text
|
||||
// looks the same":
|
||||
// * the live body is built by the streaming smd parser and the settled body
|
||||
// by renderMd + post-processing, so their textContent differs for anything
|
||||
// past single-paragraph plain text (lists, fenced code with its language
|
||||
// label, tables, `_underscore_` emphasis, the injected copy button) — a
|
||||
// text comparison would miss most real answers; and
|
||||
// * an identical answer earlier in the history (a repeated "Done.", a
|
||||
// greeting, a same-prompt resend) would delete a GENUINELY LIVE turn: while
|
||||
// the current turn is unpersisted, the last settled assistant message IS the
|
||||
// previous turn's answer.
|
||||
// So ownership is proved from state: the transcript must END with a settled
|
||||
// assistant message that THIS stream produced, and the node must carry nothing
|
||||
// the settled rebuild could not have produced.
|
||||
|
||||
// Stream identity persisted on a settled assistant message. The server stamps
|
||||
// _anchor_stream_id from the anchor-scene sidecar record (api/routes.py); the
|
||||
// client stamps _anchor_stream_id and scene.identity.stream_id at stream end
|
||||
// (_attachProjectedAnchorSceneToLastAssistant, static/messages.js).
|
||||
function _settledAssistantStreamId(message){
|
||||
if(!message) return '';
|
||||
const scene=message._anchor_activity_scene||null;
|
||||
const identity=(scene&&scene.identity)||null;
|
||||
return String(message._anchor_stream_id||(scene&&scene.stream_id)||(identity&&identity.stream_id)||'');
|
||||
}
|
||||
// The live-projection markers the #6948 preserve guard already treats as proof
|
||||
// of a live owner. A message the client still considers live is never evidence
|
||||
// that the turn has settled.
|
||||
function _messageHasLiveAssistantProjection(m){
|
||||
return !!(m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined));
|
||||
}
|
||||
// True when the live turn holds something the settled rebuild cannot have
|
||||
// produced from S.messages — an unpersisted tool card, reasoning/thinking row,
|
||||
// transparent-stream row, or a second live segment. Such a node is never
|
||||
// dropped: #3714's whole premise is that the live DOM can be AHEAD of
|
||||
// S.messages, and a tail-segment match must not discard the tool card or the
|
||||
// earlier segment above it. An unknown node shape fails closed (keep the turn).
|
||||
function _liveTurnCarriesUnsettledContent(turn){
|
||||
if(!turn||typeof turn.querySelectorAll!=='function') return true;
|
||||
if(turn.querySelectorAll(
|
||||
'.tool-card-row,.wl-reason,.agent-activity-thinking,.thinking-card-row,.transparent-event-row'
|
||||
).length) return true;
|
||||
return turn.querySelectorAll('[data-live-assistant="1"]').length>1;
|
||||
}
|
||||
function _settledTranscriptOwnsLiveTurn(sid, turn){
|
||||
if(!turn) return false;
|
||||
const msgs=(typeof S!=='undefined'&&S&&Array.isArray(S.messages))?S.messages:null;
|
||||
if(!msgs||!msgs.length) return false;
|
||||
// The transcript must END with a settled assistant answer. A trailing user
|
||||
// turn means the live turn IS the current turn — nothing of it is persisted
|
||||
// yet, so it can never be a leftover (this is the case a text comparison gets
|
||||
// wrong when the previous answer happens to read the same), and a live
|
||||
// projection anywhere means the rebuild still owns a live turn of its own.
|
||||
const last=msgs[msgs.length-1];
|
||||
if(!last||last.role!=='assistant') return false;
|
||||
if(msgs.some(_messageHasLiveAssistantProjection)) return false;
|
||||
const inflight=(typeof INFLIGHT!=='undefined'&&INFLIGHT)?INFLIGHT[sid]:null;
|
||||
// Owner of the live DOM: INFLIGHT carries the id of the stream that built it
|
||||
// (attachLiveStream), and S.activeStreamId is that same id until the terminal
|
||||
// event clears it.
|
||||
const liveStreamId=String((inflight&&inflight.streamId)||(typeof S!=='undefined'&&S&&S.activeStreamId)||'');
|
||||
const settledStreamId=_settledAssistantStreamId(last);
|
||||
if(settledStreamId){
|
||||
// Identity recorded on both sides decides — renderer- and text-independent,
|
||||
// so code blocks, lists and tables are handled like plain prose.
|
||||
if(!liveStreamId||settledStreamId!==liveStreamId) return false;
|
||||
}else{
|
||||
// No persisted identity on the tail (a turn whose scene was not worklog
|
||||
// worthy). Compare SOURCE with SOURCE — the markdown this stream produced
|
||||
// (INFLIGHT.lastAssistantText) against the persisted message content —
|
||||
// never two independently rendered DOM trees. Regeneration cannot reach
|
||||
// this branch: startRegeneration() truncates S.messages at the user turn,
|
||||
// so the tail is a user message while a regenerated answer streams.
|
||||
const streamed=String((inflight&&inflight.lastAssistantText)||'').replace(/\s+/g,' ').trim();
|
||||
if(!streamed) return false;
|
||||
const settled=String((typeof msgContent==='function'?msgContent(last):last.content)||'').replace(/\s+/g,' ').trim();
|
||||
if(!settled||streamed!==settled) return false;
|
||||
}
|
||||
return !_liveTurnCarriesUnsettledContent(turn);
|
||||
}
|
||||
|
||||
function _mergeRestoredLiveAssistantSegment(restored, existing){
|
||||
if(!restored||!existing) return;
|
||||
const existingLive=existing.querySelector('[data-live-assistant="1"]');
|
||||
@@ -9954,6 +10094,18 @@ function restoreLiveTurnHtmlForSession(sid){
|
||||
const existing=$('liveAssistantTurn');
|
||||
_mergeRestoredLiveAssistantSegment(restored, existing);
|
||||
if(existing) existing.replaceWith(restored);
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): only this branch ADDS a
|
||||
// turn — replacing an existing live turn cannot duplicate. When the settled
|
||||
// transcript already owns the stream this snapshot belongs to, appending it
|
||||
// pins a SECOND copy of the same answer under the settled one. Release the
|
||||
// stale snapshot (no other consumer can use it — it would be re-refused on
|
||||
// every later restore) and report "nothing restored", so loadSession takes the
|
||||
// same path it already takes for an INFLIGHT entry that never carried one.
|
||||
else if(typeof _settledTranscriptOwnsLiveTurn==='function'
|
||||
&&_settledTranscriptOwnsLiveTurn(sid, restored)){
|
||||
inflight.liveTurnHtml=null;
|
||||
return false;
|
||||
}
|
||||
else inner.appendChild(restored);
|
||||
// Transparent Stream: liveTurnHtml is restored via template.innerHTML, which
|
||||
// drops the property-bound onclick/onkeydown handlers wired by
|
||||
@@ -11459,6 +11611,15 @@ function _assistantMessageBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs, vis
|
||||
const isTurnFinalAssistant=!!(opts&&opts.isTurnFinalAssistant);
|
||||
const visibleText=String(visibleContent!==undefined?visibleContent:msgContent(m)||'').trim();
|
||||
const hasVisibleText=!!visibleText&&!_isAssistantEmptyPlaceholderContent(m, visibleText);
|
||||
// The caller only consults this predicate once the turn has settled (it gates
|
||||
// on `!S.busy`), so an `_live` marker seen here is a leftover of the
|
||||
// live-snapshot projection, not an ongoing stream. Folding on it hid the turn's
|
||||
// final answer inline and echoed it into the Worklog, leaving the turn with no
|
||||
// visible content until the #3875 blank-turn fail-safe revealed both copies. A
|
||||
// turn-final assistant message with visible text is the answer, so keep it out
|
||||
// of the fold; every other `_live` message folds as before. Kept as a separate
|
||||
// guard so the live rule below keeps its position.
|
||||
if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;
|
||||
if(m._live) return true;
|
||||
if(hasVisibleText&&m._anchor_activity_scene) return false;
|
||||
if(hasVisibleText&&isTurnFinalAssistant) return false;
|
||||
@@ -13141,6 +13302,17 @@ function _anchorSceneRowsForRendering(scene, opts){
|
||||
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
|
||||
const settled=!!(opts&&opts.settled);
|
||||
const live=!settled;
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): on a SETTLED turn the
|
||||
// assistant segment owns the final answer, so a process_prose row carrying
|
||||
// that same text must not also be rebuilt into the activity scene. The
|
||||
// transparent-stream renderer suppresses it per row
|
||||
// (_anchorSceneTransparentNodeForRow), but the compact-worklog row builder
|
||||
// (_anchorSceneNodeForRow) has no such guard and renders the row as a second
|
||||
// .assistant-segment above the settled one. Drop it here — the single place
|
||||
// BOTH settled renderers (and the #5839 deferred-rows path) get their rows.
|
||||
// LIVE rendering is untouched: while streaming, the inline live segment is
|
||||
// hidden and the prose row IS the visible answer.
|
||||
const settledFinalAnswer=settled?String((scene&&scene.final_answer)||'').trim():'';
|
||||
const out=[];
|
||||
const byKey=new Map();
|
||||
const liveProseTextKeys=new Map();
|
||||
@@ -13163,6 +13335,9 @@ function _anchorSceneRowsForRendering(scene, opts){
|
||||
if(_anchorSceneIsSettledSuccessfulCompression(row,settled)) continue;
|
||||
const text=String(row.text||'').trim();
|
||||
if((row.role==='prose'||row.role==='thinking')&&!text) continue;
|
||||
if(settledFinalAnswer&&row.role==='prose'
|
||||
&&typeof _anchorSceneProseDuplicatesFinalAnswer==='function'
|
||||
&&_anchorSceneProseDuplicatesFinalAnswer(text,settledFinalAnswer)) continue; // #6948 follow-up
|
||||
const key=keyFor(row);
|
||||
if(byKey.has(key)){
|
||||
const index=byKey.get(key);
|
||||
@@ -13437,6 +13612,26 @@ function _anchorSceneProseMatchesFinalAnswer(proseText, finalAnswer){
|
||||
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
|
||||
return shorter>=80 && (shorter/longer)>=0.9;
|
||||
}
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): the same near-equality
|
||||
// test as _anchorSceneProseMatchesFinalAnswer, minus its absolute `shorter>=80`
|
||||
// floor. That floor makes the matcher a no-op for every SHORT final answer
|
||||
// (greetings, "Done.", one-liners), so a prose row that is the answer minus its
|
||||
// last streamed token — e.g. "Hey! What can I help you with today" vs
|
||||
// "...today?" — was rebuilt as a SECOND assistant-segment beside the settled
|
||||
// one. The >=0.9 length ratio is what actually keeps a distinct short
|
||||
// intermediate sentence from being swallowed by a long final answer (Codex
|
||||
// #4568), so it alone is kept. Deliberately a separate helper: the per-row
|
||||
// render-time matcher above, and the tests pinning its exact predicate, stay
|
||||
// untouched.
|
||||
function _anchorSceneProseDuplicatesFinalAnswer(proseText, finalAnswer){
|
||||
if(_anchorSceneProseMatchesFinalAnswer(proseText,finalAnswer)) return true;
|
||||
const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim();
|
||||
const a=norm(proseText), b=norm(finalAnswer);
|
||||
if(!a||!b) return false;
|
||||
if(!(a.startsWith(b)||b.startsWith(a))) return false;
|
||||
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
|
||||
return (shorter/longer)>=0.9;
|
||||
}
|
||||
function _anchorSceneWorklogGroup(blocks, opts){
|
||||
if(!blocks) return null;
|
||||
const live=!!(opts&&opts.live);
|
||||
@@ -16912,6 +17107,23 @@ function _processWakeupCardHtml(info, rawText, extras){
|
||||
return `<details class="process-wakeup-card"><summary class="process-wakeup-summary"><span class="process-wakeup-toggle">${li('chevron-right',12)}</span><span class="process-wakeup-label">${li('terminal',13)}<span>${esc(t('process_wakeup_label'))}</span></span>${cmdHtml}${chip}${extras.timeHtml||''}</summary><div class="process-wakeup-detail">${extras.filesHtml||''}${patternRow}${cmdRow}<div class="msg-body process-wakeup-body">${outHtml}</div>${extras.footHtml||''}</div></details>`;
|
||||
}
|
||||
|
||||
// #2051: parse into a <template> and move the nodes instead of insertAdjacentHTML —
|
||||
// every step is idempotent, so a DOM-API wrapper (e.g. an anti-fingerprinting
|
||||
// extension) that executes the call twice cannot duplicate the block.
|
||||
function _insertSegmentBlock(seg, html){
|
||||
if(!seg) return;
|
||||
if(typeof document!=='undefined'&&typeof document.createElement==='function'){
|
||||
try{
|
||||
const tpl=document.createElement('template');
|
||||
if('content' in tpl){
|
||||
tpl.innerHTML=html;
|
||||
seg.appendChild(tpl.content);
|
||||
return;
|
||||
}
|
||||
}catch(_){ /* fall through to the string path below */ }
|
||||
}
|
||||
seg.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
function renderMessages(options){
|
||||
_lastMessageRenderAt=performance.now();
|
||||
const preserveScroll=!!(options&&options.preserveScroll);
|
||||
@@ -17676,7 +17888,7 @@ function renderMessages(options){
|
||||
if(isLastTextPart&&statusHtml){
|
||||
orderedSeg.insertAdjacentHTML('beforeend', statusHtml);
|
||||
}
|
||||
orderedSeg.insertAdjacentHTML('beforeend', `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
|
||||
_insertSegmentBlock(orderedSeg, `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
|
||||
blocks.appendChild(orderedSeg);
|
||||
if(!firstSeg) firstSeg=orderedSeg;
|
||||
});
|
||||
@@ -17736,9 +17948,9 @@ function renderMessages(options){
|
||||
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);
|
||||
if(statusHtml){
|
||||
seg.insertAdjacentHTML('beforeend', statusHtml);
|
||||
if(hasVisibleBody) seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
if(hasVisibleBody) _insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
}else if(hasVisibleBody){
|
||||
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
|
||||
seg.classList.add('assistant-segment-anchor');
|
||||
}
|
||||
@@ -18161,12 +18373,15 @@ function renderMessages(options){
|
||||
if(!cards.length&&!anchorReasonHtml&&!thinkingText) continue;
|
||||
const anchorTurn=anchorRow.closest('.assistant-turn');
|
||||
if(!anchorTurn) continue;
|
||||
// Hoisted out of the `if(!state)` block below (same expression, same
|
||||
// value) so the append path can use the ownership fact the group
|
||||
// construction already uses.
|
||||
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
|
||||
let state=activityByTurn.get(anchorTurn);
|
||||
if(!state){
|
||||
const includeTurnDuration=!durationAssignedTurns.has(anchorTurn);
|
||||
if(includeTurnDuration) durationAssignedTurns.add(anchorTurn);
|
||||
const activityKey=`assistant:${aIdx}`;
|
||||
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
|
||||
const group=ensureActivityGroup(anchorParent,{
|
||||
collapsed:true,
|
||||
anchor:anchorRow,
|
||||
@@ -18186,7 +18401,14 @@ function renderMessages(options){
|
||||
state.cards.push(...cards);
|
||||
_appendWorklogStep(state.group, anchorRow, cards, thinkingText, {
|
||||
live:false,
|
||||
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml,
|
||||
// Echo an anchor's prose as a `.wl-reason` row only when that anchor was
|
||||
// folded into this Worklog. `assistant-segment-worklog-source` is the
|
||||
// proof, and its `display:none` is the only reason the echo is not a
|
||||
// second visible copy. The group construction above already reasons
|
||||
// that way (`syncAnchorReason`); the append path did not, so an anchor
|
||||
// that escapes the fold (the turn-final answer, an `_error` message)
|
||||
// had its text rendered both inline and inside the Worklog.
|
||||
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource,
|
||||
thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:'',
|
||||
thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:'',
|
||||
seenReasons:state.seenReasons,
|
||||
@@ -18475,6 +18697,15 @@ function renderMessages(options){
|
||||
const groups=turn.querySelectorAll('.tool-worklog-group,.tool-call-group');
|
||||
let revealed=false;
|
||||
for(const group of groups){
|
||||
// A settled Worklog whose rows are still deferred (#5839) has an empty
|
||||
// textContent but is not empty in substance. Judging it empty here drops
|
||||
// through to the last-resort un-hide below, and the deferred rows then
|
||||
// materialize the same prose beside the segments it just un-hid.
|
||||
// Materialize first, then judge.
|
||||
if(group.getAttribute&&group.getAttribute('data-worklog-rows-deferred')==='1'
|
||||
&&typeof _materializeDeferredWorklogRows==='function'){
|
||||
_materializeDeferredWorklogRows(group);
|
||||
}
|
||||
if(!(group.textContent||'').trim()) continue; // empty group can't help
|
||||
if(group.classList.contains('tool-call-group-collapsed')){
|
||||
group.classList.remove('tool-call-group-collapsed');
|
||||
@@ -18593,7 +18824,17 @@ function renderMessages(options){
|
||||
// restore the whole preserved turn so nothing the user saw vanishes.
|
||||
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
|
||||
_rebuilt.replaceWith(_preservedLiveTurn);
|
||||
}else{
|
||||
}else if(!(typeof _settledTranscriptOwnsLiveTurn==='function'
|
||||
&&_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn))){
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): this is the
|
||||
// only branch that ADDS a turn — the rebuild produced no live turn of
|
||||
// its own. When the settled transcript already ends with THIS stream's
|
||||
// own answer and the preserved node carries nothing unpersisted, the
|
||||
// node is a dead leftover (the row persisted and the turn settled while
|
||||
// INFLIGHT[sid] was not yet cleaned) and appending pins a SECOND copy
|
||||
// that re-preserves itself on every later render until a reload.
|
||||
// Mid-stream the transcript ends with the user turn (or still carries a
|
||||
// live projection), so #3877 preservation is untouched.
|
||||
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
|
||||
inner.appendChild(_preservedLiveTurn);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ 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()
|
||||
_FRAME_PROOF_BITES = {"settle-worklog-frame-proof", "sidebar-idle-missing-terminal"}
|
||||
GATEWAY_ACTIVITY_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000
|
||||
@@ -267,8 +268,17 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
suffix = "" if attempts == 1 else f"-attempt-{attempt + 1}"
|
||||
log_path = artifact_dir / f"server{suffix}.log"
|
||||
log = log_path.open("w", encoding="utf-8")
|
||||
command = [sys.executable, str(repo_root / "server.py")]
|
||||
barrier_dir = env.get("HERMES_TEST_TERMINAL_BARRIER_DIR")
|
||||
if barrier_dir:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(repo_root / "tests" / "sidebar_terminal_barrier_server.py"),
|
||||
str(repo_root),
|
||||
barrier_dir,
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(repo_root / "server.py")],
|
||||
command,
|
||||
cwd=repo_root,
|
||||
env=run_env,
|
||||
stdout=log,
|
||||
@@ -286,6 +296,50 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
raise RuntimeError(f"WebUI server did not become healthy{detail}")
|
||||
|
||||
|
||||
def _exercise_sidebar_terminal_handoff(page, barrier_dir: Path, artifact_dir: Path):
|
||||
"""Order the real idle-list response before the real terminal SSE write."""
|
||||
deadline = time.monotonic() + 10
|
||||
while not (barrier_dir / "ready").exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError("terminal HTTP barrier was not reached")
|
||||
page.wait_for_timeout(10)
|
||||
observation = page.evaluate(
|
||||
"""async () => {
|
||||
const sid = S.session.session_id;
|
||||
const stream = S.activeStreamId;
|
||||
const inflight = INFLIGHT[sid];
|
||||
const source = LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source;
|
||||
const registries = window._liveAnchorRegistries;
|
||||
const registry = registries && registries.get(stream);
|
||||
await renderSessionList();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const live = document.querySelector('#liveAssistantTurn');
|
||||
return {
|
||||
serverIdle: _allSessions.some(row => row.session_id === sid && _isServerIdleSessionRow(row)),
|
||||
paneRetained: S.busy === true && S.activeStreamId === stream,
|
||||
inflightRetained: !!inflight && INFLIGHT[sid] === inflight,
|
||||
sourceRetained: !!source && source.readyState === EventSource.OPEN &&
|
||||
LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source === source,
|
||||
registryRetained: !!registry && registries.get(stream) === registry,
|
||||
liveRows: live ? live.querySelectorAll('[data-anchor-scene-row="1"]').length : 0,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
(artifact_dir / "sidebar-before-terminal.json").write_text(
|
||||
json.dumps(observation, indent=2), encoding="utf-8"
|
||||
)
|
||||
page.screenshot(path=str(artifact_dir / "sidebar-before-terminal.png"), full_page=True)
|
||||
for invariant in ("serverIdle", "paneRetained", "inflightRetained", "sourceRetained", "registryRetained"):
|
||||
assert observation[invariant] is True, {
|
||||
"message": "idle sidebar response revoked the live terminal handoff",
|
||||
"invariant": invariant,
|
||||
"observation": observation,
|
||||
}
|
||||
assert observation["liveRows"] > 0, observation
|
||||
if TEST_BITE != "sidebar-idle-missing-terminal":
|
||||
(barrier_dir / "release").touch()
|
||||
|
||||
|
||||
class DeterministicGateway:
|
||||
"""A localhost-only Gateway Runs server with test-controlled phase gates."""
|
||||
|
||||
@@ -733,11 +787,12 @@ def main() -> int:
|
||||
"drop-anchor-persistence",
|
||||
"drop-terminal-anchor-row",
|
||||
"settle-worklog-frame-proof",
|
||||
"sidebar-idle-missing-terminal",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; "
|
||||
"expected one of '', 'drop-anchor-persistence', "
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof'"
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof', 'sidebar-idle-missing-terminal'"
|
||||
)
|
||||
if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error":
|
||||
raise ValueError(
|
||||
@@ -782,6 +837,12 @@ def main() -> int:
|
||||
"NO_PROXY": "127.0.0.1,localhost",
|
||||
"no_proxy": "127.0.0.1,localhost",
|
||||
})
|
||||
env.pop("HERMES_TEST_TERMINAL_BARRIER_DIR", None)
|
||||
terminal_barrier_dir = None
|
||||
if TEST_BITE in _FRAME_PROOF_BITES and scenario == "normal":
|
||||
terminal_barrier_dir = state_dir / "terminal-barrier"
|
||||
terminal_barrier_dir.mkdir()
|
||||
env["HERMES_TEST_TERMINAL_BARRIER_DIR"] = str(terminal_barrier_dir)
|
||||
proc = None
|
||||
log = None
|
||||
log_path = None
|
||||
@@ -873,7 +934,7 @@ def main() -> int:
|
||||
# must never expose an expanded empty Worklog shell or an activity-less
|
||||
# gap. Sample after rAF callbacks for a frame have drained; DOM mutations
|
||||
# within one task or earlier rAF callbacks are not user-visible frames.
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
if TEST_BITE in _FRAME_PROOF_BITES:
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
const samples = [];
|
||||
@@ -941,12 +1002,17 @@ def main() -> int:
|
||||
timeout=10000,
|
||||
)
|
||||
gateway.release_terminal.set()
|
||||
if terminal_barrier_dir is not None:
|
||||
_exercise_sidebar_terminal_handoff(page, terminal_barrier_dir, artifact_dir)
|
||||
page.wait_for_function(
|
||||
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
|
||||
((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""",
|
||||
arg=FINAL_TEXT,
|
||||
timeout=15000,
|
||||
timeout=11000 if TEST_BITE == "sidebar-idle-missing-terminal" else 15000,
|
||||
)
|
||||
if TEST_BITE == "sidebar-idle-missing-terminal":
|
||||
assert not (terminal_barrier_dir / "release").exists(), "terminal frame was unexpectedly released"
|
||||
print("OK missing terminal: canonical snapshot settled the still-OPEN transport before terminal delivery")
|
||||
else:
|
||||
gateway.release_terminal.set()
|
||||
page.wait_for_function(
|
||||
@@ -958,7 +1024,7 @@ def main() -> int:
|
||||
)
|
||||
session_id = page.evaluate("S.session && S.session.session_id")
|
||||
assert session_id, "active session id missing after settlement"
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
if TEST_BITE in _FRAME_PROOF_BITES:
|
||||
page.wait_for_timeout(100)
|
||||
settle_proof = page.evaluate(
|
||||
"window.__lifecycleSettleFrameProof && window.__lifecycleSettleFrameProof.stop()"
|
||||
@@ -1176,6 +1242,8 @@ def main() -> int:
|
||||
exit_code = 1
|
||||
return 1
|
||||
finally:
|
||||
if terminal_barrier_dir is not None:
|
||||
(terminal_barrier_dir / "release").touch()
|
||||
gateway.close()
|
||||
if browser is not None:
|
||||
browser.close()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real page/loadSession/renderers with deterministic session and SSE fixtures.
|
||||
|
||||
No agent, provider, production state, or credentials. Tests DOM reconstruction,
|
||||
not a real provider/network reconnect.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from browser_conversation_lifecycle import _start_webui_server, _terminate_process
|
||||
|
||||
ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
def fixture(count):
|
||||
tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'},
|
||||
preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)]
|
||||
rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call',
|
||||
source_event_type='tool_complete', status='completed', order_index=i,
|
||||
tool=t, payload=t) for i, t in enumerate(tools)]
|
||||
return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000',
|
||||
messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='',
|
||||
anchor_activity_scene=dict(version='activity_scene_v1',
|
||||
identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'),
|
||||
activity_rows=rows))
|
||||
|
||||
|
||||
INIT = """
|
||||
// A controlling service worker bypasses Playwright's page route fixtures in
|
||||
// WebKit. This test targets reconnect rendering, not PWA cache behavior.
|
||||
if(window===window.top && 'serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness'));
|
||||
}
|
||||
window.fixtureSources=[];
|
||||
class FixtureEventSource {
|
||||
static OPEN=1; static CONNECTING=0; static CLOSED=2;
|
||||
constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);}
|
||||
addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);}
|
||||
removeEventListener(){}
|
||||
close(){this.readyState=2;}
|
||||
emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});}
|
||||
}
|
||||
window.EventSource=FixtureEventSource;
|
||||
"""
|
||||
|
||||
|
||||
def session_route(session, sid, workspace):
|
||||
def handle(route):
|
||||
query = parse_qs(urlsplit(route.request.url).query)
|
||||
requested = query.get('session_id', [''])[0]
|
||||
data = session if requested == sid else dict(
|
||||
session_id='idle-fixture', messages=[], message_count=0,
|
||||
tool_calls=[], workspace=workspace)
|
||||
route.fulfill(json={'session': data})
|
||||
return handle
|
||||
|
||||
|
||||
def main():
|
||||
tool_count = int(os.environ.get('TOOL_COUNT', '470'))
|
||||
snapshot = fixture(tool_count)
|
||||
stream_id = snapshot['stream_id']
|
||||
sid = 'fixture'
|
||||
with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp:
|
||||
state = Path(temp)
|
||||
env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ}
|
||||
env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp,
|
||||
HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'),
|
||||
HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1',
|
||||
HERMES_WEBUI_AGENT_DIR=str(state/'no-agent'))
|
||||
proc, log, _, base = _start_webui_server(ROOT, env, state)
|
||||
results = []
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
for engine in os.environ.get('BROWSERS','chromium,webkit').split(','):
|
||||
browser = getattr(pw,engine).launch(headless=True)
|
||||
try:
|
||||
for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','):
|
||||
for width in [1280,390]:
|
||||
context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True)
|
||||
context.add_init_script(INIT)
|
||||
page = context.new_page()
|
||||
|
||||
errors=[]
|
||||
page.on('pageerror',lambda e, errors=errors:errors.append(str(e)))
|
||||
session = dict(session_id=sid,title='Reconnect performance fixture',model='',
|
||||
workspace=temp,messages=[],message_count=0,tool_calls=[],
|
||||
active_stream_id=stream_id,pending_user_message='Inspect the fixture',
|
||||
pending_started_at=1,runtime_journal_snapshot=snapshot)
|
||||
page.route('**/api/session?*',session_route(session,sid,temp))
|
||||
page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True}))
|
||||
page.goto(base,wait_until='load')
|
||||
# WebKit's wait_for_function uses eval, blocked by
|
||||
# the app CSP even with bypass_csp. Inspector
|
||||
# evaluation still works; wait on actual boot state.
|
||||
deadline=time.monotonic()+30
|
||||
while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"):
|
||||
assert time.monotonic()<deadline, ('boot timeout', errors)
|
||||
page.wait_for_timeout(50)
|
||||
page.evaluate("""mode=>{
|
||||
window._chatActivityDisplayMode=mode;window._showThinking=true;
|
||||
window._simplifiedToolCalling=true;
|
||||
window.redrawCount=0;
|
||||
const render=window._renderLiveAnchorActivitySceneForStream;
|
||||
window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);};
|
||||
}""",mode)
|
||||
measured=page.evaluate("""async sid=>{
|
||||
const t=performance.now();await loadSession(sid);
|
||||
const loadMs=performance.now()-t;
|
||||
const deadline=performance.now()+5000;
|
||||
while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()<deadline){
|
||||
await new Promise(r=>setTimeout(r,10));
|
||||
}
|
||||
return {ms:loadMs,redraws:window.redrawCount,
|
||||
tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
busy:S.busy,
|
||||
sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)};
|
||||
}""",sid)
|
||||
result=dict(engine=engine,mode=mode,width=width,
|
||||
**{k:v for k,v in measured.items() if k!='sources'})
|
||||
print(json.dumps(result),flush=True)
|
||||
results.append(result)
|
||||
if not os.environ.get('MEASURE_BASELINE'):
|
||||
assert measured['redraws']<=2,result
|
||||
expected=0 if mode=='hide_all_activity' else tool_count
|
||||
assert measured['tools']==expected,result
|
||||
assert measured['busy'],result
|
||||
assert measured['sources'], (result, errors)
|
||||
cursor=parse_qs(urlsplit(measured['sources'][-1]).query)
|
||||
assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors)
|
||||
if mode!='hide_all_activity':
|
||||
# Subsequent real SSE handler updates still paint the existing owner.
|
||||
updated=page.evaluate("""({sid,stream})=>{
|
||||
const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1);
|
||||
if(!source)throw new Error('missing chat stream');
|
||||
source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001');
|
||||
source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002');
|
||||
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
|
||||
}""",{'sid':sid,'stream':stream_id})
|
||||
assert updated=={'rows':expected+1,'result':True},updated
|
||||
switched=page.evaluate("""async sid=>{
|
||||
await loadSession('idle-fixture');
|
||||
await loadSession(sid);
|
||||
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
|
||||
}""",sid)
|
||||
assert switched==updated,switched
|
||||
artifact=os.environ.get('SCREENSHOT_DIR')
|
||||
if artifact:
|
||||
Path(artifact).mkdir(parents=True,exist_ok=True)
|
||||
page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png'))
|
||||
assert not errors,errors
|
||||
context.close()
|
||||
finally:
|
||||
browser.close()
|
||||
output=os.environ.get('METRICS_FILE')
|
||||
if output:
|
||||
Path(output).write_text(json.dumps(results,indent=2))
|
||||
finally:
|
||||
_terminate_process(proc)
|
||||
log.close()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Test-only server for the public settle-frame gate's cross-channel race.
|
||||
|
||||
Hold the actual HTTP terminal frame after the worker has finished. The browser
|
||||
keeps its native, open EventSource while /api/sessions independently reports idle.
|
||||
No browser state, renderer, persistence result, or terminal payload is mocked.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("expected repository root and isolated barrier directory")
|
||||
repo_root, barrier_dir = (Path(value) for value in sys.argv[1:])
|
||||
sys.path.insert(0, str(repo_root))
|
||||
import server
|
||||
from api import routes
|
||||
|
||||
ready = barrier_dir / "ready"
|
||||
release = barrier_dir / "release"
|
||||
original = routes._sse_with_id
|
||||
|
||||
def terminal_barrier(handler, event, data, event_id=None):
|
||||
if event == "done" and handler.path.startswith("/api/chat/stream?"):
|
||||
ready.touch()
|
||||
deadline = time.monotonic() + 15
|
||||
while not release.exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("test did not release the terminal HTTP frame")
|
||||
time.sleep(0.005)
|
||||
return original(handler, event, data, event_id)
|
||||
|
||||
routes._sse_with_id = terminal_barrier
|
||||
server.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""#7569 review fix: timed-flush must not leave the folded index stale.
|
||||
|
||||
The ~10 Hz reasoning throttle in the stream callback clears the coalescing
|
||||
buffer before emitting:
|
||||
|
||||
put('reasoning', {'text': _reasoning_buffer[0]})
|
||||
_reasoning_buffer[0] = ''
|
||||
# ← _reasoning_buffer_index.reset() was missing here
|
||||
|
||||
``_CompactEchoIndex`` records raw offsets *relative to the buffer's accumulated
|
||||
length*. Clearing the buffer without resetting the index leaves it describing
|
||||
text that is no longer there, so the next ``cut_to`` returns a raw offset that
|
||||
no longer corresponds to the buffer — the live-echo strip then truncates the
|
||||
buffer at a phantom position and falsely marks the interim as
|
||||
``reasoning_echo: true`` (leaks a truncated copy into both the SSE stream and
|
||||
the durable journal).
|
||||
|
||||
This file pins the invariant at the *call site*, not just on the index class:
|
||||
revert the fix commit and the first test fails.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
STREAMING = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _timed_flush_block() -> str:
|
||||
"""The ``if now - _reasoning_last_put[0] >= 0.1:`` throttle branch body."""
|
||||
match = re.search(
|
||||
r"if now - _reasoning_last_put\[0\] >= 0\.1:\n(.*?)(?=\n\s{16}(?:# Track reasoning deltas|_metering_reasoning_deltas))",
|
||||
STREAMING,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "timed-flush throttle branch not found in api/streaming.py"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_timed_flush_clears_index_together_with_the_buffer():
|
||||
"""Clearing the reasoning buffer must also reset the folded index, in the
|
||||
same throttle branch, after the clear (ordering matters: the reset cannot
|
||||
sit in an unrelated branch or before the clear)."""
|
||||
block = _timed_flush_block()
|
||||
|
||||
assert "_reasoning_buffer[0] = ''" in block, (
|
||||
"timed-flush branch must clear the coalesced reasoning buffer"
|
||||
)
|
||||
assert "_reasoning_buffer_index.reset()" in block, (
|
||||
"timed-flush branch must reset the folded index when it clears the "
|
||||
"buffer — leaving the index stale makes later cut_to() offsets "
|
||||
"describe text that is no longer in the buffer (#7569 re-gate)"
|
||||
)
|
||||
# reset must come AFTER the clear, and be at the same indent depth (8
|
||||
# spaces inside the throttle branch), not in a sibling branch.
|
||||
clear_pos = block.index("_reasoning_buffer[0] = ''")
|
||||
reset_pos = block.index("_reasoning_buffer_index.reset()")
|
||||
assert reset_pos > clear_pos, (
|
||||
"reset must follow the buffer clear, so the next append starts from "
|
||||
"an empty raw-length base"
|
||||
)
|
||||
assert re.search(r"^\s{20}_reasoning_buffer_index\.reset\(\)", block, re.M), (
|
||||
"reset must be inside the timed-flush branch (deeper than the clear), "
|
||||
"not in an outer/sibling scope"
|
||||
)
|
||||
|
||||
|
||||
def test_all_buffer_clears_are_index_safe():
|
||||
"""Every site that zeroes ``_reasoning_buffer[0]`` must reset the folded
|
||||
index immediately after — grep the codebase for clear sites and require
|
||||
each to be paired with the index reset."""
|
||||
for clear_match in re.finditer(r"_reasoning_buffer\[0\] = ''", STREAMING):
|
||||
window = STREAMING[clear_match.start(): clear_match.start() + 400]
|
||||
reset_present = re.search(
|
||||
r"_reasoning_buffer_index\.reset\(\)", window
|
||||
)
|
||||
assert reset_present, (
|
||||
"a buffer clear at api/streaming.py has no adjacent "
|
||||
"_reasoning_buffer_index.reset() — stale offsets would corrupt "
|
||||
"the next cut_to() call"
|
||||
)
|
||||
@@ -79,20 +79,19 @@ RAISED_EXCEPTION_SELF_HEAL_MARKER = (
|
||||
"retrying stream after credential refresh')"
|
||||
)
|
||||
|
||||
# Each region ends at the cache write that stores the signature it just
|
||||
# computed. Bounding the region at the write -- rather than at the next
|
||||
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
|
||||
# *before* it is cached: a call moved below its own write lands outside the
|
||||
# region and fails, instead of passing while the write consumes a stale
|
||||
# `_agent_sig`.
|
||||
# Each region ends at the atomic registration/publication call that consumes
|
||||
# the signature it just computed. The shared helper now writes the cache under
|
||||
# the Stop lock; the call boundary must still follow fresh signature computation.
|
||||
# A signature call moved below its publication lands outside the region and
|
||||
# fails, rather than silently reusing a stale `_agent_sig` on a retry.
|
||||
INITIAL_SEND_CACHE_WRITE_MARKER = (
|
||||
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
|
||||
"if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):"
|
||||
)
|
||||
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
|
||||
"_SAC[session_id] = (agent, _agent_sig)"
|
||||
"if not _register_agent_if_current(agent, _agent_sig):"
|
||||
)
|
||||
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
|
||||
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
|
||||
"if not _register_agent_if_current(_heal_agent, _agent_sig):"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Stop must not lose to cache publication after an Agent was registered.
|
||||
|
||||
Run the real worker and Stop primitive. Pause immediately after the registration
|
||||
lock is released, not inside the constructor covered by the older tests. Only
|
||||
external Agent/provider calls are synthetic; no live credentials are used.
|
||||
"""
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from api import config, session_lifecycle, streaming
|
||||
from tests.test_steer_worker_boundaries import worker_scene as worker_scene
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["initial", "cached", "returned", "exception"])
|
||||
@pytest.mark.parametrize("cancellation", ["stop", "event-only", "detached-only", "successor", "same-agent-successor", "same-agent-completed", "none"])
|
||||
def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkeypatch, path, cancellation):
|
||||
scene = worker_scene
|
||||
reached, release = threading.Event(), threading.Event()
|
||||
created, publications, finalized = [], [], []
|
||||
worker_id = [None]
|
||||
paused = [False]
|
||||
successor = object()
|
||||
stop_interrupt_seen = threading.Event()
|
||||
successor_interrupt_count = None
|
||||
monkeypatch.setattr(session_lifecycle, "_sessions", {})
|
||||
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
|
||||
config.register_session_writeback_owner("original", "run")
|
||||
|
||||
def on_init():
|
||||
created.append(scene.agent)
|
||||
interrupt = scene.agent.interrupt
|
||||
def observed_interrupt(reason):
|
||||
result = interrupt(reason)
|
||||
stop_interrupt_seen.set()
|
||||
return result
|
||||
scene.agent.interrupt = observed_interrupt
|
||||
|
||||
def on_run():
|
||||
assert scene.lock.owner != threading.get_ident(), "provider invocation under stream lock"
|
||||
if path in ("returned", "exception") and len(created) == 1:
|
||||
if path == "exception":
|
||||
raise RuntimeError("401 unauthorized")
|
||||
scene.result = {"error": "401 unauthorized", "messages": []}
|
||||
else:
|
||||
scene.result = None
|
||||
|
||||
scene.on_init, scene.on_run = on_init, on_run
|
||||
if path == "cached":
|
||||
scene.run()
|
||||
assert scene.calls.count("run") == 1
|
||||
assert config.SESSION_AGENT_CACHE["original"][0] is created[0]
|
||||
scene.calls.clear()
|
||||
config.STREAMS["run"] = scene.events
|
||||
config.STREAM_SESSION_OWNERS["run"] = "original"
|
||||
scene.session.active_stream_id = "run"
|
||||
scene.session.pending_user_message = "Do the task."
|
||||
scene.session.pending_started_at = 2.0
|
||||
scene.session.save()
|
||||
config.register_session_writeback_owner("original", "run")
|
||||
stop_interrupt_seen.clear()
|
||||
|
||||
class ObservedCache(OrderedDict):
|
||||
def __setitem__(self, key, value):
|
||||
if key == "original":
|
||||
publications.append((value[0], scene.lock.owner == threading.get_ident(),
|
||||
"run" in config.STREAMS))
|
||||
super().__setitem__(key, value)
|
||||
|
||||
cache = ObservedCache()
|
||||
# Preserve an existing cache hit without counting fixture setup as publication.
|
||||
for key, value in config.SESSION_AGENT_CACHE.items():
|
||||
OrderedDict.__setitem__(cache, key, value)
|
||||
monkeypatch.setattr(config, "SESSION_AGENT_CACHE", cache)
|
||||
if hasattr(streaming, "SESSION_AGENT_CACHE"):
|
||||
monkeypatch.setattr(streaming, "SESSION_AGENT_CACHE", cache)
|
||||
monkeypatch.setattr(streaming, "_attempt_credential_self_heal", lambda *a, **kw: {
|
||||
"provider": "openai", "api_key": "synthetic-publication-fixture",
|
||||
"base_url": "http://127.0.0.1:1/v1",
|
||||
})
|
||||
|
||||
target_count = 2 if path in ("returned", "exception") else 1
|
||||
|
||||
def after_registration_unlock():
|
||||
if (worker_id[0] != threading.get_ident() or paused[0]
|
||||
or len(created) != target_count
|
||||
or config.AGENT_INSTANCES.get("run") is not created[-1]):
|
||||
return
|
||||
paused[0] = True
|
||||
reached.set()
|
||||
assert release.wait(8), "post-registration barrier timed out"
|
||||
|
||||
scene.lock.after_release = after_registration_unlock
|
||||
finalize = streaming._finalize_cancelled_turn
|
||||
|
||||
def observed_finalize(*args, **kwargs):
|
||||
assert scene.lock.owner != threading.get_ident(), "finalize under stream lock"
|
||||
finalized.append(True)
|
||||
return finalize(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(streaming, "_finalize_cancelled_turn", observed_finalize)
|
||||
|
||||
def run():
|
||||
worker_id[0] = threading.get_ident()
|
||||
scene.run()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
stop_future = None
|
||||
future = pool.submit(run)
|
||||
try:
|
||||
assert reached.wait(8), "worker never released target registration lock"
|
||||
retained = config.CANCEL_FLAGS["run"]
|
||||
if cancellation in ("stop", "successor", "same-agent-successor", "same-agent-completed"):
|
||||
stop_future = pool.submit(streaming.cancel_stream, "run")
|
||||
assert retained.wait(5), "Stop did not publish cancellation"
|
||||
assert stop_interrupt_seen.wait(5), "Stop has not finished its own interrupt"
|
||||
with config.STREAMS_LOCK:
|
||||
assert "run" not in config.STREAMS
|
||||
if cancellation in ("successor", "same-agent-successor", "same-agent-completed"):
|
||||
if cancellation.startswith("same-agent"):
|
||||
successor = created[-1]
|
||||
config.register_session_writeback_owner("original", "successor")
|
||||
with config.SESSION_AGENT_CACHE_LOCK:
|
||||
OrderedDict.__setitem__(cache, "original", (successor, "new-signature"))
|
||||
session_lifecycle.register_agent("original", successor)
|
||||
scene.session.active_stream_id = "successor"
|
||||
scene.session.pending_user_message = "Successor request"
|
||||
if cancellation == "same-agent-completed":
|
||||
config.clear_session_writeback_owner_if_owned("original", "successor")
|
||||
scene.session.active_stream_id = None
|
||||
scene.session.pending_user_message = None
|
||||
else:
|
||||
config.AGENT_INSTANCES["successor"] = successor
|
||||
config.register_active_run("successor", session_id="original", backend="legacy")
|
||||
if cancellation == "same-agent-successor":
|
||||
with successor.pending_lock:
|
||||
successor.pending.append("Successor-only guidance")
|
||||
successor_interrupt_count = scene.calls.count("interrupt")
|
||||
elif cancellation == "event-only":
|
||||
with config.STREAMS_LOCK:
|
||||
retained.set()
|
||||
config.CANCEL_FLAGS.pop("run")
|
||||
elif cancellation == "detached-only":
|
||||
with config.STREAMS_LOCK:
|
||||
config.STREAMS.pop("run")
|
||||
assert not retained.is_set()
|
||||
finally:
|
||||
release.set()
|
||||
future.result(timeout=12)
|
||||
if stop_future is not None:
|
||||
assert stop_future.result(timeout=12) is True
|
||||
|
||||
prior_runs = 1 if path in ("returned", "exception") else 0
|
||||
if cancellation == "none":
|
||||
assert scene.calls.count("run") == prior_runs + 1
|
||||
assert config.SESSION_AGENT_CACHE["original"][0] is created[-1]
|
||||
else:
|
||||
assert scene.calls.count("run") == prior_runs, "cancelled candidate was invoked"
|
||||
cached = config.SESSION_AGENT_CACHE.get("original")
|
||||
if not cancellation.startswith("same-agent"):
|
||||
assert not cached or cached[0] is not created[-1], "cancelled candidate stayed reusable"
|
||||
assert finalized, "cancellation did not reach terminal settlement"
|
||||
if successor_interrupt_count is not None:
|
||||
assert scene.calls.count("interrupt") == successor_interrupt_count, "stale worker interrupted its successor"
|
||||
assert config.SESSION_AGENT_CACHE["original"][0] is successor
|
||||
assert session_lifecycle._sessions["original"]["agent"] is successor
|
||||
if cancellation == "same-agent-successor":
|
||||
assert successor.pending == ["Successor-only guidance"], "stale terminal drain stole successor guidance"
|
||||
if cancellation == "same-agent-completed":
|
||||
assert scene.session.active_stream_id is None
|
||||
else:
|
||||
assert config.AGENT_INSTANCES["successor"] is successor
|
||||
assert scene.session.active_stream_id == "successor"
|
||||
assert scene.session.pending_user_message == "Successor request"
|
||||
assert all(held and live for _, held, live in publications), (
|
||||
"Agent cache publication escaped the stream/Stop admission boundary", publications)
|
||||
assert "run" not in config.AGENT_INSTANCES
|
||||
assert "run" not in config.ACTIVE_RUNS
|
||||
|
||||
|
||||
def test_cache_publication_and_registration_exclude_competing_stop(worker_scene, monkeypatch):
|
||||
"""Block the actual cache write; Stop cannot observe a half-published Agent."""
|
||||
scene = worker_scene
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
interrupted_state = []
|
||||
interrupt_observed = threading.Event()
|
||||
monkeypatch.setattr(session_lifecycle, "_sessions", {})
|
||||
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
|
||||
config.register_session_writeback_owner("original", "run")
|
||||
|
||||
class PublicationBarrier(OrderedDict):
|
||||
def __setitem__(self, key, value):
|
||||
if key == "original":
|
||||
entered.set()
|
||||
assert release.wait(8), "cache publication was not released"
|
||||
super().__setitem__(key, value)
|
||||
|
||||
cache = PublicationBarrier()
|
||||
monkeypatch.setattr(config, "SESSION_AGENT_CACHE", cache)
|
||||
if hasattr(streaming, "SESSION_AGENT_CACHE"):
|
||||
monkeypatch.setattr(streaming, "SESSION_AGENT_CACHE", cache)
|
||||
|
||||
def on_init():
|
||||
candidate = scene.agent
|
||||
interrupt = candidate.interrupt
|
||||
|
||||
def observed_interrupt(reason):
|
||||
interrupted_state.append((
|
||||
cache.get("original", (None,))[0] is candidate,
|
||||
session_lifecycle._sessions.get("original", {}).get("agent") is candidate,
|
||||
))
|
||||
result = interrupt(reason)
|
||||
interrupt_observed.set()
|
||||
return result
|
||||
|
||||
candidate.interrupt = observed_interrupt
|
||||
|
||||
scene.on_init = on_init
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
worker = pool.submit(scene.run)
|
||||
stop = None
|
||||
try:
|
||||
assert entered.wait(8), "worker never reached cache publication"
|
||||
retained = config.CANCEL_FLAGS["run"]
|
||||
# Wait at the first publication unlock until Stop has won admission,
|
||||
# so the provider cannot outrun the controlled schedule.
|
||||
worker_ident = scene.lock.owner
|
||||
def wait_for_stop():
|
||||
if threading.get_ident() == worker_ident:
|
||||
scene.lock.after_release = None
|
||||
assert interrupt_observed.wait(5), "Stop did not interrupt the published Agent"
|
||||
scene.lock.after_release = wait_for_stop
|
||||
stop = pool.submit(streaming.cancel_stream, "run")
|
||||
assert scene.lock.contender.wait(5), "Stop bypassed publication's stream lock"
|
||||
assert not retained.is_set(), "Stop observed half-published Agent state"
|
||||
finally:
|
||||
release.set()
|
||||
worker.result(timeout=12)
|
||||
if stop is not None:
|
||||
assert stop.result(timeout=12) is True
|
||||
assert interrupted_state[0] == (True, True), "Stop did not see coherent cache/lifecycle publication"
|
||||
assert "run" not in scene.calls
|
||||
assert "original" not in cache
|
||||
@@ -416,6 +416,10 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
legacy_metadata_source = _function_source(
|
||||
_ui_js(), "_legacySettledFallbackHasToolMetadata"
|
||||
)
|
||||
# #2051: renderMessages() inserts a message block through this helper, so it is
|
||||
# evaluated with it. The shim's createElement() returns no template `content`, so the
|
||||
# helper takes its insertAdjacentHTML fallback here, exactly as before.
|
||||
insert_block_source = _function_source(_ui_js(), "_insertSegmentBlock")
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
class FakeClassList {{
|
||||
@@ -702,6 +706,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
|
||||
eval({json.dumps(transparent_source)});
|
||||
eval({json.dumps(legacy_metadata_source)});
|
||||
eval({json.dumps(insert_block_source)});
|
||||
eval({json.dumps(render_source)});
|
||||
|
||||
const toolResult = {{ role: 'tool', tool_call_id: 'toolu_1', content: 'tool result' }};
|
||||
|
||||
@@ -241,8 +241,18 @@ def test_anchor_scene_persistence_rejects_cross_profile_write(tmp_path, monkeypa
|
||||
SimpleNamespace(command="POST"),
|
||||
SimpleNamespace(path="/api/session/anchor-scene"),
|
||||
) is True
|
||||
assert captured.get("status") == 404
|
||||
assert "payload" not in captured # success j() never called
|
||||
# #7710: the generic request-guard now mirrors the detail-load
|
||||
# endpoint's contract — a session owned by a KNOWN other profile
|
||||
# yields 409 ``session_profile_mismatch`` so the client can offer
|
||||
# to switch to it (#5419). The 404 self-heal path is preserved for
|
||||
# the None-profile (unknown/legacy) case.
|
||||
assert captured.get("status") == 409, captured
|
||||
assert captured.get("payload") == {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": "foreignprofile1",
|
||||
"profile": "profile-b",
|
||||
}
|
||||
raw = json.loads((session_dir / "foreignprofile1.json").read_text(encoding="utf-8"))
|
||||
assert not raw.get("anchor_activity_scenes"), (
|
||||
"cross-profile request must NOT persist anchor_activity_scenes"
|
||||
|
||||
@@ -601,6 +601,85 @@ class TestApprovalHTTPEndpoints:
|
||||
r._gateway_queues.pop(sid, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
|
||||
def test_identity_v1_non_head_run_approval_settles_exact_producer(self, monkeypatch):
|
||||
"""A relayed non-head run approval must wake only its own parked waiter."""
|
||||
from api import route_approvals as ra
|
||||
from api import routes as r
|
||||
|
||||
sid = f"identity-v1-non-head-{uuid.uuid4().hex[:8]}"
|
||||
run_id = f"run-identity-v1-{uuid.uuid4().hex[:8]}"
|
||||
head = {
|
||||
"approval_id": "head-approval",
|
||||
"run_id": run_id,
|
||||
"command": "head command",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
target = {
|
||||
"approval_id": "target-approval",
|
||||
"run_id": run_id,
|
||||
"command": "target command",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
head_entry = _ApprovalEntry(dict(head))
|
||||
target_entry = _ApprovalEntry(dict(target))
|
||||
captured = {}
|
||||
relays = []
|
||||
|
||||
def fake_j(_handler, data, status=200, extra_headers=None):
|
||||
captured.update(payload=data, status=status)
|
||||
return data
|
||||
|
||||
def fake_respond(_self, got_run_id, got_approval_id, choice):
|
||||
relays.append((got_run_id, got_approval_id, choice))
|
||||
return {"resolved": 1}
|
||||
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
||||
monkeypatch.setattr(
|
||||
"api.config.gateway_supports_approval_identity_v1", lambda *_args: True
|
||||
)
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues[sid] = [head_entry, target_entry]
|
||||
try:
|
||||
ra.submit_gateway_pending_mirror(sid, dict(target_entry.data))
|
||||
mirror = ra.gateway_pending_mirror(
|
||||
sid, approval_id=target["approval_id"], run_id=run_id
|
||||
)
|
||||
assert mirror is not None
|
||||
|
||||
r._handle_approval_respond(
|
||||
object(),
|
||||
{
|
||||
"session_id": sid,
|
||||
"choice": "deny",
|
||||
"approval_id": target["approval_id"],
|
||||
"run_id": run_id,
|
||||
"mirror_token": mirror[ra._GATEWAY_MIRROR_TOKEN],
|
||||
},
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"payload": {"ok": True, "choice": "deny", "relayed": True},
|
||||
"status": 200,
|
||||
}
|
||||
assert relays == [(run_id, target["approval_id"], "deny")]
|
||||
assert target_entry.event.is_set()
|
||||
assert target_entry.result == "deny"
|
||||
assert not head_entry.event.is_set()
|
||||
with _lock:
|
||||
assert r._gateway_queues[sid] == [head_entry]
|
||||
for _ in range(2):
|
||||
with _lock:
|
||||
ra.reconcile_gateway_pending_mirror_locked(sid)
|
||||
assert ra.gateway_pending_mirror(
|
||||
sid, approval_id=target["approval_id"], run_id=run_id
|
||||
) is None
|
||||
finally:
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, 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
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""A wake-up refused for a transient reason the WebUI did not cause must not
|
||||
consume the durable delivery budget.
|
||||
|
||||
Scenario: while the Agent runtime is being replaced, the still-running WebUI
|
||||
answers every server-side wake-up with ``409 agent_runtime_stale
|
||||
retryable=True``. Releasing the claim as a plain failure each time exhausts the
|
||||
bounded budget within seconds and terminally drops a completion whose origin
|
||||
session is alive and waiting.
|
||||
|
||||
Contract under test (``api/background_process._start_async_delegation_wakeup_turn``):
|
||||
|
||||
* a refusal whose payload says ``retryable: True`` (stale runtime), a paused
|
||||
wake-up or a session busy with another stream releases the claim with
|
||||
``retryable=True`` so the core refunds the attempt;
|
||||
* every other rejection keeps the historical bounded behaviour;
|
||||
* an older core whose ``release_event_delivery`` has no ``retryable`` keyword
|
||||
still gets a plain release (compatibility, never an exception).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from api import background_process as bp
|
||||
from api import config as cfg
|
||||
from api import process_event_utils as peu
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout=3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return bool(predicate())
|
||||
|
||||
|
||||
def _reset() -> None:
|
||||
cfg.PROCESS_SESSION_INDEX.clear()
|
||||
cfg.PENDING_BG_TASK_COMPLETIONS.clear()
|
||||
cfg.BG_TASK_COMPLETE_EVENTS_SEEN.clear()
|
||||
cfg.DEFERRED_PROCESS_WAKEUPS.clear()
|
||||
reset = getattr(peu, "_reset_legacy_async_delivery_dedupe_for_tests", None)
|
||||
if reset is not None:
|
||||
reset()
|
||||
|
||||
|
||||
def _event():
|
||||
return {
|
||||
"type": "async_delegation",
|
||||
"delegation_id": "deleg_retryable",
|
||||
"session_key": "webui-session-1",
|
||||
"status": "completed",
|
||||
"is_batch": True,
|
||||
"results": [{"task_index": 0, "status": "completed", "summary": "verdict"}],
|
||||
"dispatched_at": time.time() - 2,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self):
|
||||
import queue
|
||||
|
||||
self.completion_queue = queue.Queue()
|
||||
|
||||
|
||||
def _install_core(monkeypatch, *, supports_retryable: bool):
|
||||
calls = {"release": [], "complete": []}
|
||||
mod = types.ModuleType("tools.async_delegation")
|
||||
mod.claim_event_delivery = lambda evt, consumer: f"claim:{consumer}"
|
||||
mod.complete_event_delivery = lambda evt, claim_id: calls["complete"].append(claim_id)
|
||||
mod.get_durable_delegation = lambda delegation_id: {"delivery_state": "pending", "delivery_attempts": 1}
|
||||
mod.restore_undelivered_completions = lambda queue_: 0
|
||||
if supports_retryable:
|
||||
def _release(evt, claim_id, *, retryable=False):
|
||||
calls["release"].append((claim_id, retryable))
|
||||
else:
|
||||
def _release(evt, claim_id): # pre-refund core: no keyword at all
|
||||
calls["release"].append((claim_id, None))
|
||||
mod.release_event_delivery = _release
|
||||
pkg = sys.modules.get("tools") or types.ModuleType("tools")
|
||||
monkeypatch.setitem(sys.modules, "tools", pkg)
|
||||
monkeypatch.setitem(sys.modules, "tools.async_delegation", mod)
|
||||
monkeypatch.setattr(pkg, "async_delegation", mod, raising=False)
|
||||
return calls
|
||||
|
||||
|
||||
def _run(monkeypatch, response, *, supports_retryable=True):
|
||||
_reset()
|
||||
calls = _install_core(monkeypatch, supports_retryable=supports_retryable)
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "start_session_turn", lambda *a, **k: response)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 10.0)
|
||||
registry = _Registry()
|
||||
evt = _event()
|
||||
claim = peu.claim_async_delegation_delivery(evt, "webui-background")
|
||||
assert claim is not None
|
||||
try:
|
||||
bp._start_async_delegation_wakeup_turn(
|
||||
"webui-session-1", "verdict", delegation_id="deleg_retryable",
|
||||
evt=evt, claim=claim, process_registry=registry,
|
||||
)
|
||||
assert _wait_until(lambda: len(calls["release"]) == 1)
|
||||
assert calls["complete"] == []
|
||||
return calls["release"][0]
|
||||
finally:
|
||||
_reset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", [
|
||||
{"_status": 409, "error": "runtime changed", "type": "agent_runtime_stale", "retryable": True},
|
||||
{"_status": 503, "error": "temporarily unavailable", "retryable": True},
|
||||
{"_status": 409, "error": "process_wakeup_paused"},
|
||||
{"_status": 409, "error": "session already has an active stream", "active_stream_id": "s1"},
|
||||
])
|
||||
def test_transient_refusal_releases_with_refund(monkeypatch, response):
|
||||
_claim, retryable = _run(monkeypatch, response)
|
||||
assert retryable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", [
|
||||
{"_status": 500, "error": "boom"},
|
||||
{"_status": 404, "error": "Session not found"},
|
||||
{"_status": 400, "error": "message is required"},
|
||||
{"_status": 409, "error": "conflict", "retryable": False},
|
||||
None,
|
||||
])
|
||||
def test_hard_rejection_keeps_bounded_budget(monkeypatch, response):
|
||||
_claim, retryable = _run(monkeypatch, response)
|
||||
assert retryable is False
|
||||
|
||||
|
||||
def test_old_core_without_retryable_keyword_still_releases(monkeypatch):
|
||||
_claim, retryable = _run(
|
||||
monkeypatch,
|
||||
{"_status": 409, "error": "x", "type": "agent_runtime_stale", "retryable": True},
|
||||
supports_retryable=False,
|
||||
)
|
||||
assert retryable is None # plain release happened, no TypeError escaped
|
||||
|
||||
|
||||
def test_real_core_signature_is_detected():
|
||||
def new_core(evt, claim_id, *, retryable=False):
|
||||
return None
|
||||
|
||||
def kwargs_core(evt, claim_id, **kwargs):
|
||||
return None
|
||||
|
||||
def old_core(evt, claim_id):
|
||||
return None
|
||||
|
||||
assert peu._release_accepts_retryable(new_core) is True
|
||||
assert peu._release_accepts_retryable(kwargs_core) is True
|
||||
assert peu._release_accepts_retryable(old_core) is False
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Regression coverage for notify_on_complete across a WebUI restart."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
@@ -159,3 +160,102 @@ def test_recovery_is_fail_soft_without_agent(monkeypatch):
|
||||
|
||||
assert bp.recover_processes_for_webui() == 0
|
||||
assert bp._PROCESS_RECOVERY_DONE is False
|
||||
|
||||
|
||||
def test_recovery_skips_process_whose_session_is_gone(monkeypatch, caplog):
|
||||
"""A checkpointed process whose owner has been pruned (the resolver's
|
||||
``KeyError(sid)`` contract) must be silently skipped, while a neighbouring
|
||||
live process in the same recovery sweep still rebinds. Regression for
|
||||
issue #7753: previously every vanished owner logged a full WARNING
|
||||
traceback, indistinguishable from a real fault.
|
||||
"""
|
||||
calls = {"registered": []}
|
||||
|
||||
class FakeRegistry:
|
||||
def recover_from_checkpoint(self):
|
||||
return 2 # two checkpointed processes survive restart
|
||||
|
||||
def list_sessions(self):
|
||||
return [
|
||||
{"session_id": "proc_vanished", "detached": True},
|
||||
{"session_id": "proc_live", "detached": True},
|
||||
]
|
||||
|
||||
def get(self, process_id):
|
||||
if process_id == "proc_vanished":
|
||||
return _FakeProcessSession("vanished-session")
|
||||
if process_id == "proc_live":
|
||||
return _FakeProcessSession("live-session")
|
||||
raise AssertionError(process_id)
|
||||
|
||||
fake_registry = FakeRegistry()
|
||||
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
|
||||
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
|
||||
monkeypatch.setattr(
|
||||
bp,
|
||||
"register_process_session",
|
||||
lambda key, sid: calls["registered"].append((key, sid)),
|
||||
)
|
||||
|
||||
def get_session(sid, metadata_only=False):
|
||||
# Mirrors api.models._resolve_session_once's contract: missing
|
||||
# sessions raise KeyError, NOT None.
|
||||
if sid == "vanished-session":
|
||||
raise KeyError(sid)
|
||||
return SimpleNamespace(id=sid)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="api.background_process"):
|
||||
result = bp.recover_processes_for_webui(fake_registry, get_session)
|
||||
|
||||
assert result == 2
|
||||
# The live process is rebound; the vanished one is silently skipped.
|
||||
assert calls["registered"] == [("live-session", "live-session")]
|
||||
# No WARNING emitted for the vanished-owner case.
|
||||
warning_records = [
|
||||
r for r in caplog.records if r.levelno >= logging.WARNING
|
||||
]
|
||||
assert warning_records == [], (
|
||||
"vanished-session KeyError must not produce a WARNING, got: "
|
||||
f"{[r.getMessage() for r in warning_records]}"
|
||||
)
|
||||
assert "Could not resolve recovered WebUI process" not in caplog.text
|
||||
|
||||
|
||||
def test_recovery_still_warns_on_unexpected_resolve_failure(monkeypatch, caplog):
|
||||
"""Non-KeyError failures from the resolver (e.g. ``OSError`` on an
|
||||
unreadable sidecar) must keep entering the existing WARNING path. This
|
||||
pins that the fix for #7753 is narrow and does not over-broaden the
|
||||
silent-skip contract.
|
||||
"""
|
||||
class FakeRegistry:
|
||||
def recover_from_checkpoint(self):
|
||||
return 1
|
||||
|
||||
def list_sessions(self):
|
||||
return [{"session_id": "proc_unexpected", "detached": True}]
|
||||
|
||||
def get(self, process_id):
|
||||
assert process_id == "proc_unexpected"
|
||||
return _FakeProcessSession("webui-session")
|
||||
|
||||
fake_registry = FakeRegistry()
|
||||
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
|
||||
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
|
||||
|
||||
def get_session(sid, metadata_only=False):
|
||||
raise OSError("sidecar unreadable")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="api.background_process"):
|
||||
result = bp.recover_processes_for_webui(fake_registry, get_session)
|
||||
|
||||
assert result == 1
|
||||
warning_records = [
|
||||
r for r in caplog.records if r.levelno >= logging.WARNING
|
||||
]
|
||||
assert any(
|
||||
"Could not resolve recovered WebUI process 'proc_unexpected'" in r.getMessage()
|
||||
for r in warning_records
|
||||
), (
|
||||
"OSError from the resolver must still emit a WARNING, got: "
|
||||
f"{[r.getMessage() for r in warning_records]}"
|
||||
)
|
||||
@@ -227,14 +227,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
# Patch handle_get to raise BrokenPipeError
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# send_response should NEVER be called for the 500 — client is gone
|
||||
handler.send_response.assert_not_called()
|
||||
@@ -248,12 +248,12 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
raise self._route_raises
|
||||
|
||||
import server as _server_mod
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler._handle_write(handler, _fake_route)
|
||||
finally:
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_not_called()
|
||||
|
||||
@@ -286,9 +286,9 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
|
||||
closed_stdout = io.StringIO()
|
||||
closed_stdout.close()
|
||||
@@ -299,7 +299,7 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
finally:
|
||||
sys.stdout = original_stdout
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
|
||||
@@ -312,14 +312,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# Should send 500 for real errors
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
@@ -334,14 +334,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_not_called()
|
||||
|
||||
@@ -355,14 +355,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_not_called()
|
||||
|
||||
@@ -406,14 +406,14 @@ class TestServer500ResponseSafety(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# send_response WAS called (we tried to send 500), but write failed
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
@@ -431,14 +431,14 @@ class TestServer500ResponseSafety(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# send_response WAS called (we tried to send 500), but write failed
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
|
||||
@@ -291,6 +291,13 @@ class TestLiveModelsCustomProviderFallback:
|
||||
"model": "gpt-5.5",
|
||||
"models": {"gpt-5.5-mini": {}},
|
||||
"base_url": "https://right.codes/codex/v1",
|
||||
# dict-shaped ``models`` is per-model metadata (Agent setup
|
||||
# flow) and only narrows the live catalog when discovery is
|
||||
# explicitly off — the new #7165 contract. This test still
|
||||
# wants provider-scoping (no sibling leak), so pin the
|
||||
# catalog via ``discover_models: false`` instead of relying
|
||||
# on the removed dict-as-allowlist behavior.
|
||||
"discover_models": False,
|
||||
},
|
||||
{
|
||||
"name": "infini-ai",
|
||||
@@ -308,7 +315,7 @@ class TestLiveModelsCustomProviderFallback:
|
||||
resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex")
|
||||
|
||||
assert resp["provider"] == "custom:rightcode-codex"
|
||||
assert [m["id"] for m in resp["models"]] == ["gpt-5.5", "gpt-5.5-mini"]
|
||||
assert [m["id"] for m in resp["models"]] == ["gpt-5.5-mini"]
|
||||
|
||||
def test_bare_custom_fallback_ignores_named_custom_provider_models(self, monkeypatch):
|
||||
"""Bare custom only represents unnamed custom entries, not named siblings."""
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
from types import SimpleNamespace
|
||||
|
||||
from api.commands import list_commands
|
||||
from api.commands import list_commands, _ALLOWED_AGENT_COMMANDS, _AGENT_COMMAND_ALIASES
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -15,6 +18,36 @@ COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_js_set_members(source: str, varname: str) -> set[str]:
|
||||
"""Return the string members of a `const NAME=new Set([...])` literal."""
|
||||
m = re.search(rf"const\s+{re.escape(varname)}\s*=\s*new Set\(\[(.*?)\]\)", source, re.S)
|
||||
assert m, f"could not find const {varname}=new Set([...]) in source"
|
||||
return set(re.findall(r"'([^']*)'", m.group(1)))
|
||||
|
||||
|
||||
def _canonical_agent_names(names: set[str]) -> set[str]:
|
||||
"""Map underscore alias forms to canonical names via api/commands.py's map."""
|
||||
return {_AGENT_COMMAND_ALIASES.get(n, n) for n in names}
|
||||
|
||||
|
||||
def _extract_busy_intercept_block() -> str:
|
||||
"""Extract the busy-path slash intercept `if` block verbatim from send()."""
|
||||
marker = "Busy-control slash commands must be intercepted"
|
||||
marker_idx = MESSAGES_JS.find(marker)
|
||||
assert marker_idx != -1
|
||||
start = MESSAGES_JS.find("if(text.startsWith('/')&&!literalSlash){", marker_idx)
|
||||
assert start != -1
|
||||
depth = 0
|
||||
for i in range(start, len(MESSAGES_JS)):
|
||||
if MESSAGES_JS[i] == "{":
|
||||
depth += 1
|
||||
elif MESSAGES_JS[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return MESSAGES_JS[start : i + 1]
|
||||
raise AssertionError("unbalanced braces while extracting busy intercept block")
|
||||
|
||||
|
||||
def test_api_commands_exposes_cli_only_metadata_for_webui_intercept():
|
||||
"""CLI-only commands must remain visible so the frontend can explain them."""
|
||||
registry = [
|
||||
@@ -163,6 +196,30 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
aliases: ['plugin_review'],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}},
|
||||
{{
|
||||
name: 'agents',
|
||||
description: 'Manage background agents',
|
||||
category: 'Session',
|
||||
aliases: ['tasks'],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}},
|
||||
{{
|
||||
name: 'sessions',
|
||||
description: 'List sessions',
|
||||
category: 'Session',
|
||||
aliases: [],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}},
|
||||
{{
|
||||
name: 'resume',
|
||||
description: 'Resume a session',
|
||||
category: 'Session',
|
||||
aliases: [],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}}
|
||||
]
|
||||
}};
|
||||
@@ -356,8 +413,8 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
|
||||
assert result["delegate_names"] == []
|
||||
assert result["incident_names"] == ["incident-review"]
|
||||
assert result["incident_sources"] == ["bundle"]
|
||||
assert result["triage_names"] == ["triage-review"]
|
||||
assert result["triage_sources"] == ["agent"]
|
||||
assert result["triage_names"] == []
|
||||
assert result["triage_sources"] == []
|
||||
assert result["plugin_names"] == ["plugin-review"]
|
||||
assert result["plugin_sources"] == ["plugin"]
|
||||
assert "skills" in result["skills_names"]
|
||||
@@ -659,3 +716,374 @@ def test_builtin_command_opt_outs_do_not_hit_agent_metadata_lookup():
|
||||
assert optout_idx != -1
|
||||
assert metadata_idx != -1
|
||||
assert "if(_parsedCmd&&!_cmd)" in intercept[optout_idx:metadata_idx + 120]
|
||||
|
||||
|
||||
# ── #6951: autocomplete must only announce commands the WebUI dispatches ─────
|
||||
|
||||
|
||||
def test_non_dispatchable_agent_command_hidden_from_autocomplete():
|
||||
"""#6951: registry commands the WebUI does not dispatch (e.g. /agents) must
|
||||
not be advertised, since submitting them would fall through to plain chat."""
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
await loadAgentCommandMetadata(true);
|
||||
const agents = await getSlashAutocompleteMatches('/agents');
|
||||
const ag = await getSlashAutocompleteMatches('/ag');
|
||||
return {
|
||||
agents_names: agents.map(item => item.name),
|
||||
ag_names: ag.map(item => item.name)
|
||||
};
|
||||
"""
|
||||
)
|
||||
assert result["agents_names"] == []
|
||||
assert result["ag_names"] == []
|
||||
|
||||
|
||||
def test_dispatchable_agent_commands_stay_in_autocomplete():
|
||||
"""#6951: commands the WebUI does dispatch -- backend exec allowlist and
|
||||
native WebUI behaviors -- must keep appearing in autocomplete."""
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
await loadAgentCommandMetadata(true);
|
||||
const reload = await getSlashAutocompleteMatches('/reload');
|
||||
const sessions = await getSlashAutocompleteMatches('/sessions');
|
||||
const resume = await getSlashAutocompleteMatches('/resume');
|
||||
const plugin = await getSlashAutocompleteMatches('/plugin');
|
||||
return {
|
||||
reload_names: reload.map(item => item.name),
|
||||
reload_sources: reload.map(item => item.source),
|
||||
sessions_names: sessions.map(item => item.name),
|
||||
resume_names: resume.map(item => item.name),
|
||||
plugin_names: plugin.map(item => item.name)
|
||||
};
|
||||
"""
|
||||
)
|
||||
assert result["reload_names"] == ["reload-skills"]
|
||||
assert result["reload_sources"] == ["agent"]
|
||||
assert result["sessions_names"] == ["sessions"]
|
||||
assert result["resume_names"] == ["resume"]
|
||||
assert result["plugin_names"] == ["plugin-review"]
|
||||
|
||||
|
||||
def test_autocomplete_allowlist_is_exact_parity_with_dispatchers():
|
||||
"""#6951 (re-gate): the announced allowlist must be in exact agreement with
|
||||
BOTH real dispatch authorities -- api/commands.py's _ALLOWED_AGENT_COMMANDS
|
||||
(the /api/commands/exec allowlist) and messages.js's
|
||||
_AGENT_COMMANDS_RUN_ON_WEBUI (the send() dispatch set). This compares the
|
||||
actual parsed sets, so drift in ANY authority fails the test: a command
|
||||
added to one allowlist but not the others, a renamed alias, or a removed
|
||||
entry all break parity here."""
|
||||
announced = _extract_js_set_members(COMMANDS_JS, "_WEBUI_DISPATCHABLE_AGENT_COMMANDS")
|
||||
dispatched = _extract_js_set_members(MESSAGES_JS, "_AGENT_COMMANDS_RUN_ON_WEBUI")
|
||||
|
||||
# The backend-exec family must agree exactly across all three authorities
|
||||
# (canonical names after alias normalization).
|
||||
backend_allowed = set(_ALLOWED_AGENT_COMMANDS)
|
||||
assert _canonical_agent_names(dispatched) == backend_allowed
|
||||
assert _canonical_agent_names(announced) & backend_allowed == backend_allowed
|
||||
|
||||
# Announced backend-exec commands are exactly the backend-exec allowlist
|
||||
# (no announced exec command outside /api/commands/exec, none missing).
|
||||
announced_backend = _canonical_agent_names(announced) & backend_allowed
|
||||
assert announced_backend == backend_allowed
|
||||
|
||||
# Every remaining announced command must be a native WebUI behavior that
|
||||
# send() handles without an agent round-trip (moa/sessions/resume/pet).
|
||||
native = announced - announced_backend
|
||||
assert native == {"moa", "sessions", "resume", "pet"}
|
||||
|
||||
# The plugin transport is parity by rule, not by list: the filter accepts
|
||||
# category==='Plugin' and the dispatcher routes the exact same value.
|
||||
assert "category==='Plugin'" in COMMANDS_JS
|
||||
assert "_agentCmd.category==='Plugin'" in MESSAGES_JS
|
||||
|
||||
|
||||
def _run_shared_classic_realm_js() -> dict:
|
||||
"""Evaluate commands.js then messages.js in ONE vm realm -- exactly how
|
||||
static/index.html loads them (consecutive non-module `defer` classic
|
||||
scripts share a single global lexical realm). A duplicate top-level
|
||||
`const` across the two files throws during the second script's
|
||||
declaration instantiation and aborts it before any statement runs, so the
|
||||
second script's dispatch consts would never bind. Browser-only globals
|
||||
referenced at load time are stubbed; runtime (call-time) references are
|
||||
out of scope for this guard.
|
||||
"""
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const vm = require('vm');
|
||||
const ctx = vm.createContext({{
|
||||
console,
|
||||
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
|
||||
t: (key) => key,
|
||||
document: {{ addEventListener(){{}}, getElementById(){{ return null; }}, hidden: false }},
|
||||
window: {{ addEventListener(){{}}, speechSynthesis: {{ speaking: false, paused: false }} }},
|
||||
location: {{ href: 'http://localhost/', protocol: 'http:', host: 'localhost' }},
|
||||
navigator: {{ userAgent: 'test' }},
|
||||
setTimeout: () => 0, clearTimeout: () => 0,
|
||||
setInterval: () => 0, clearInterval: () => 0,
|
||||
requestAnimationFrame: () => 0, cancelAnimationFrame: () => 0,
|
||||
fetch: async () => ({{}}),
|
||||
EventSource: function(){{}},
|
||||
MutationObserver: function(){{ this.observe=()=>{{}}; }},
|
||||
Blob: function(){{}}, FileReader: function(){{ this.readAsText=()=>{{}}; }},
|
||||
URL: function(){{ this.href=''; }}, FormData: function(){{}},
|
||||
WebSocket: function(){{}},
|
||||
crypto: {{ getRandomValues: (arr) => arr }},
|
||||
CustomEvent: function(){{}}
|
||||
}});
|
||||
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
|
||||
let secondError = null;
|
||||
try {{
|
||||
vm.runInContext({json.dumps(MESSAGES_JS)}, ctx);
|
||||
}} catch (e) {{
|
||||
secondError = String(e && e.message || e);
|
||||
}}
|
||||
const bound = vm.runInContext(`({{
|
||||
runOnWebui: typeof _AGENT_COMMANDS_RUN_ON_WEBUI,
|
||||
dispatchable: typeof _WEBUI_DISPATCHABLE_AGENT_COMMANDS,
|
||||
backendConst: typeof _BACKEND_EXECUTED_AGENT_COMMANDS
|
||||
}})`, ctx);
|
||||
process.stdout.write(JSON.stringify({{ secondError, ...bound }}));
|
||||
"""
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle:
|
||||
handle.write(script)
|
||||
script_path = Path(handle.name)
|
||||
try:
|
||||
proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True)
|
||||
finally:
|
||||
script_path.unlink(missing_ok=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_commands_and_messages_evaluate_in_one_shared_classic_script_realm():
|
||||
"""#6962 (re-gate): commands.js and messages.js must coexist as classic
|
||||
scripts in the same global realm (static/index.html loads them back to
|
||||
back). A duplicate top-level `const _BACKEND_EXECUTED_AGENT_COMMANDS`
|
||||
across both files previously threw
|
||||
`SyntaxError: Identifier ... has already been declared` on messages.js,
|
||||
aborting its evaluation entirely. Both files must evaluate cleanly in one
|
||||
realm, the dispatch consts of BOTH files must be bound, and the removed
|
||||
duplicate authority mirror must not exist in the shared namespace."""
|
||||
out = _run_shared_classic_realm_js()
|
||||
assert out["secondError"] is None, (
|
||||
f"messages.js failed to evaluate after commands.js in the shared realm: "
|
||||
f"{out['secondError']}"
|
||||
)
|
||||
assert out["runOnWebui"] == "object", (
|
||||
"messages.js dispatch const _AGENT_COMMANDS_RUN_ON_WEBUI not bound in "
|
||||
"the shared realm -- declaration instantiation was aborted"
|
||||
)
|
||||
assert out["dispatchable"] == "object", (
|
||||
"commands.js dispatch const _WEBUI_DISPATCHABLE_AGENT_COMMANDS not "
|
||||
"bound in the shared realm"
|
||||
)
|
||||
assert out["backendConst"] == "undefined", (
|
||||
"duplicate/unused _BACKEND_EXECUTED_AGENT_COMMANDS mirror must not be "
|
||||
"declared in the shared realm (declared once -> it would collide; "
|
||||
"declared in one file -> it is dead authority drift)"
|
||||
)
|
||||
|
||||
|
||||
def test_busy_path_intercepts_stop_before_mode_routing():
|
||||
"""#6951: while busy, /stop must cancel the active run immediately instead
|
||||
of being steered/queued as the literal text '/stop'."""
|
||||
busy_idx = MESSAGES_JS.find("Busy-control slash commands must be intercepted")
|
||||
assert busy_idx != -1
|
||||
mode_idx = MESSAGES_JS.find("const defaultMessageMode=", busy_idx)
|
||||
assert mode_idx != -1
|
||||
busy_block = MESSAGES_JS[busy_idx:mode_idx]
|
||||
assert "['steer','interrupt','queue','terminal','goal','yolo','stop']" in busy_block
|
||||
assert "cmdStop" in busy_block or "COMMANDS.find(c=>c.name===_pc.name)" in busy_block
|
||||
|
||||
|
||||
def _run_production_autocomplete_js(commands_payload: list[dict], script_body: str) -> dict:
|
||||
"""Run the REAL getMatchingCommands/autocomplete pipeline from commands.js
|
||||
against a /api/commands payload (defaults to the real hermes_cli registry
|
||||
serialized exactly as list_commands() produces it)."""
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const vm = require('vm');
|
||||
const ctx = {{
|
||||
console,
|
||||
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
|
||||
t: (key) => key,
|
||||
api: async (path) => {{
|
||||
if (path === '/api/commands') return {{ commands: {json.dumps(commands_payload)} }};
|
||||
if (path === '/api/commands/bundles') return {{ bundles: [] }};
|
||||
if (path === '/api/skills') return {{ skills: [] }};
|
||||
throw new Error('unexpected api path: ' + path);
|
||||
}}
|
||||
}};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
|
||||
(async () => {{
|
||||
const result = await vm.runInContext(`(async () => {{
|
||||
{script_body}
|
||||
}})()`, ctx);
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})().catch(err => {{
|
||||
console.error(err && err.stack || err);
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle:
|
||||
handle.write(script)
|
||||
script_path = Path(handle.name)
|
||||
try:
|
||||
proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True)
|
||||
finally:
|
||||
script_path.unlink(missing_ok=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _real_registry_payload() -> list[dict] | None:
|
||||
"""Serialize the real hermes_cli COMMAND_REGISTRY via the production
|
||||
list_commands() path. Returns None when hermes_cli is unavailable."""
|
||||
try:
|
||||
return list_commands()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def test_real_registry_announced_commands_are_all_dispatchable():
|
||||
"""#6951 (re-gate): with the REAL hermes_cli registry as /api/commands
|
||||
payload, every command the production autocomplete announces must be
|
||||
dispatchable -- backend-exec via /api/commands/exec, a native WebUI
|
||||
behavior, or a plugin command. /agents (in the registry, NOT dispatched)
|
||||
must not be announced. This is the announced <= dispatched invariant
|
||||
exercised against the real production data path."""
|
||||
payload = _real_registry_payload()
|
||||
if payload is None:
|
||||
pytest.skip("hermes_cli registry unavailable")
|
||||
announced_by_prefix = {}
|
||||
for cmd in payload:
|
||||
name = str(cmd.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
result = _run_production_autocomplete_js(
|
||||
payload,
|
||||
f"""
|
||||
await loadAgentCommandMetadata(true);
|
||||
const matches = await getSlashAutocompleteMatches('/{name}');
|
||||
return matches.map(item => ({{ name: item.name, source: item.source }}));
|
||||
""",
|
||||
)
|
||||
announced_by_prefix[name] = result
|
||||
|
||||
backend_allowed = set(_ALLOWED_AGENT_COMMANDS)
|
||||
for name, rows in announced_by_prefix.items():
|
||||
for row in rows:
|
||||
if row["source"] not in ("agent", "plugin"):
|
||||
continue # builtin/subarg/skill rows are out of scope
|
||||
assert row["name"] in backend_allowed or row["name"] in (
|
||||
"moa",
|
||||
"sessions",
|
||||
"resume",
|
||||
"pet",
|
||||
), f"{row['name']} announced but not dispatchable"
|
||||
|
||||
# /agents is in the real registry but must not be announced (it is neither
|
||||
# backend-exec nor a native WebUI behavior nor a plugin).
|
||||
assert announced_by_prefix.get("agents", []) == [], announced_by_prefix.get("agents")
|
||||
|
||||
|
||||
def test_alias_typed_text_reaches_exec_dispatch():
|
||||
"""#6951 (re-gate): typing an underscore alias (/reload_skills) must
|
||||
resolve through getAgentCommandMetadata to the canonical name, which is
|
||||
what the dispatcher allowlist tests -- so alias forms dispatch without
|
||||
needing their own autocomplete row."""
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
const byAlias = await getAgentCommandMetadata('reload_skills');
|
||||
const canonical = byAlias && byAlias.name;
|
||||
return { canonical, dispatched: canonical === 'reload-skills' };
|
||||
"""
|
||||
)
|
||||
assert result["canonical"] == "reload-skills"
|
||||
assert result["dispatched"] is True
|
||||
|
||||
|
||||
def _run_busy_intercept_js(script_body: str) -> dict:
|
||||
"""Execute the REAL busy-path slash intercept block from messages.js with
|
||||
the REAL COMMANDS table + parseCommand from commands.js. This exercises the
|
||||
production routing branch -- not just its source text.
|
||||
|
||||
The harness defines `async function busyIntercept(text, literalSlash)` in
|
||||
the vm context (so it closes over the real COMMANDS/parseCommand) and the
|
||||
script_body then calls it. The block's inner `return;` exits run() early
|
||||
when the command is intercepted, so `intercepted: true` is returned."""
|
||||
block = _extract_busy_intercept_block()
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const vm = require('vm');
|
||||
const calls = [];
|
||||
const ctx = {{
|
||||
console,
|
||||
localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }},
|
||||
t: (key) => key,
|
||||
S: {{
|
||||
busy: true,
|
||||
activeStreamId: 'stream-1',
|
||||
session: {{ session_id: 'sess-1' }},
|
||||
pendingFiles: []
|
||||
}},
|
||||
cancelStream: async (reason) => {{ calls.push('cancelStream:' + reason); return true; }},
|
||||
showToast: () => {{}},
|
||||
$: () => ({{ value: '' }}),
|
||||
autoResize: () => {{}},
|
||||
api: async () => ({{}}),
|
||||
_trySteer: async () => {{ calls.push('steer'); }},
|
||||
queueSessionMessage: () => {{ calls.push('queue'); }}
|
||||
}};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext({json.dumps(COMMANDS_JS)}, ctx);
|
||||
vm.runInContext(`async function busyIntercept(text, literalSlash){{
|
||||
let reachedModeRouting = false;
|
||||
const run = async () => {{
|
||||
{block}
|
||||
reachedModeRouting = true;
|
||||
}};
|
||||
await run();
|
||||
return {{ intercepted: !reachedModeRouting }};
|
||||
}}`, ctx);
|
||||
(async () => {{
|
||||
const result = await vm.runInContext(`(async () => {{
|
||||
{script_body}
|
||||
}})()`, ctx);
|
||||
process.stdout.write(JSON.stringify({{ result, calls }}));
|
||||
}})().catch(err => {{
|
||||
console.error(err && err.stack || err);
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle:
|
||||
handle.write(script)
|
||||
script_path = Path(handle.name)
|
||||
try:
|
||||
proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True)
|
||||
finally:
|
||||
script_path.unlink(missing_ok=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_busy_stop_executes_real_cancel_branch():
|
||||
"""#6951 (re-gate): executing the real busy-intercept branch from messages.js
|
||||
with the real COMMANDS table must route /stop to cmdStop -> cancelStream,
|
||||
and must NOT fall through to steer/queue routing."""
|
||||
out = _run_busy_intercept_js(
|
||||
"""
|
||||
const r1 = await busyIntercept('/stop', false);
|
||||
const r2 = await busyIntercept('/agents', false);
|
||||
return { stop: r1, agents: r2 };
|
||||
"""
|
||||
)
|
||||
# /stop is intercepted by the busy branch (cmdStop -> cancelStream ran,
|
||||
# mode routing never reached), /agents is NOT a busy-control command so it
|
||||
# falls through to mode routing (documented: it is no longer announced).
|
||||
assert out["result"]["stop"] == {"intercepted": True}
|
||||
assert out["result"]["agents"] == {"intercepted": False}
|
||||
assert any(c.startswith("cancelStream:slash-stop") for c in out["calls"]), out["calls"]
|
||||
assert not any(c.startswith("steer") for c in out["calls"]), out["calls"]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Comma-combined ``Content-Length`` must not over-close a healthy connection.
|
||||
|
||||
RFC 9110 §5.3 lets a list-valued field arrive either as repeated header lines or
|
||||
as a single comma-combined line, and the two spellings are equivalent. So
|
||||
``Content-Length: 0, 0`` describes the same bodyless request as two separate
|
||||
``Content-Length: 0`` headers, and rejecting it must keep the keep-alive.
|
||||
|
||||
``_declared_content_lengths()`` originally ran ``isdigit()`` across the whole raw
|
||||
value, so the comma failed the check, the field read as *unreadable*, and every
|
||||
helper-backed rejection armed ``Connection: close`` on a request with nothing
|
||||
queued behind it. That is the over-close half of this PR's contract — the mirror
|
||||
image of the bug it fixes, and the one that silently drops pooled connections.
|
||||
|
||||
The dangerous direction has to stay closed, so the disagreeing and malformed
|
||||
spellings are asserted here alongside the recoverable ones: ``0, 42`` genuinely
|
||||
cannot frame a body and must still be refused with the close armed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.helpers import request_declares_body, unreadable_content_length
|
||||
|
||||
|
||||
class _Hdrs(dict):
|
||||
"""email.message-like: ``get()`` returns the first value, ``get_all()`` all."""
|
||||
|
||||
def get(self, k, default=None):
|
||||
for key, val in self.items():
|
||||
if key.lower() == k.lower():
|
||||
return val[0] if isinstance(val, list) else val
|
||||
return default
|
||||
|
||||
def get_all(self, k, default=None):
|
||||
out = []
|
||||
for key, val in self.items():
|
||||
if key.lower() == k.lower():
|
||||
out.extend(val if isinstance(val, list) else [val])
|
||||
return out or default
|
||||
|
||||
|
||||
class _H:
|
||||
def __init__(self, headers):
|
||||
self.headers = headers
|
||||
|
||||
|
||||
def _handler(cl):
|
||||
return _H(_Hdrs({"Content-Length": cl}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0, 0", "00, 00", "0 , 0", "0,0", "0,\t0"])
|
||||
def test_agreeing_bodyless_comma_list_is_readable_and_bodyless(value):
|
||||
"""An agreeing all-zero list is framed, bodyless, and must keep keep-alive."""
|
||||
h = _handler(value)
|
||||
assert unreadable_content_length(h) is False, (
|
||||
f"Content-Length {value!r} read as unreadable; a bodyless rejection would "
|
||||
"then arm Connection: close and drop a healthy pooled connection"
|
||||
)
|
||||
assert bool(request_declares_body(h)) is False, (
|
||||
f"Content-Length {value!r} declares no body, so nothing is queued"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["42, 42", "42,42", "042, 42"])
|
||||
def test_agreeing_nonzero_comma_list_is_readable_with_a_body(value):
|
||||
"""An agreeing nonzero list frames a real body: readable, and a body IS pending."""
|
||||
h = _handler(value)
|
||||
assert unreadable_content_length(h) is False, value
|
||||
assert bool(request_declares_body(h)) is True, value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0, 42", "42, 0", "0, 0, 42"])
|
||||
def test_disagreeing_comma_list_stays_unreadable(value):
|
||||
"""Disagreeing members cannot frame a body — must stay refused, close armed."""
|
||||
h = _handler(value)
|
||||
assert unreadable_content_length(h) is True, (
|
||||
f"Content-Length {value!r} disagrees; there is no honest length to drain, "
|
||||
"so it must be refused with the connection armed for close"
|
||||
)
|
||||
assert bool(request_declares_body(h)) is True, value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["0, banana", "banana, 0", "0,", ",0", "0, ,0", "0, +0", "0, \xa00", "0, 0x5"],
|
||||
)
|
||||
def test_malformed_comma_members_stay_unreadable(value):
|
||||
"""One bad member poisons the whole field: fail closed, never treat as zero."""
|
||||
h = _handler(value)
|
||||
assert unreadable_content_length(h) is True, (
|
||||
f"Content-Length {value!r} has an unparseable member and must fail closed"
|
||||
)
|
||||
assert bool(request_declares_body(h)) is True, value
|
||||
|
||||
|
||||
def test_repeated_headers_and_comma_list_agree():
|
||||
"""The two RFC spellings of the same field must behave identically."""
|
||||
repeated = _H(_Hdrs({"Content-Length": ["0", "0"]}))
|
||||
combined = _handler("0, 0")
|
||||
assert unreadable_content_length(repeated) == unreadable_content_length(combined)
|
||||
assert bool(request_declares_body(repeated)) == bool(request_declares_body(combined))
|
||||
|
||||
repeated_bad = _H(_Hdrs({"Content-Length": ["0", "42"]}))
|
||||
combined_bad = _handler("0, 42")
|
||||
assert unreadable_content_length(repeated_bad) is True
|
||||
assert unreadable_content_length(combined_bad) is True
|
||||
@@ -0,0 +1,218 @@
|
||||
"""``_CompactEchoIndex`` must agree with the raw backward walk, cheaply.
|
||||
|
||||
The index is the echo-match authority for both the journal rebuild
|
||||
(``api/routes.py::_run_journal_live_snapshot``) and the live stream
|
||||
(``api/streaming.py``). It replaces a per-interim raw backward walk that
|
||||
rescanned the whole transcript's whitespace — quadratic on whitespace-heavy
|
||||
text (#7569). Two contracts must hold for the swap to be safe:
|
||||
|
||||
1. **Equivalence** — incremental appends must reach the same cut point as a
|
||||
raw walk over the concatenated text, for every shape including chunked
|
||||
appends, whitespace-only chunks, and exotic Unicode whitespace.
|
||||
2. **Cost** — the index must never rescan the buffer it has already folded:
|
||||
a probe after a wide whitespace span must not walk that span.
|
||||
"""
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _compact(value):
|
||||
return re.sub(r'\s+', '', str(value or ''))
|
||||
|
||||
|
||||
def _raw_reference(value, suffix):
|
||||
"""Deliberately naive oracle: probe every cut index, fold the tail."""
|
||||
raw = str(value or '')
|
||||
candidate = _compact(suffix)
|
||||
if not raw or not candidate:
|
||||
return raw, False
|
||||
for idx in range(len(raw) + 1):
|
||||
if _compact(raw[idx:]) == candidate:
|
||||
return raw[:idx].rstrip(), True
|
||||
return raw, False
|
||||
|
||||
|
||||
def _cases():
|
||||
cases = [
|
||||
# Exact echo at the end.
|
||||
("bla bla bla une conclusion", "une conclusion"),
|
||||
# Echo differing only by whitespace shape.
|
||||
("bla bla une conclusion", "une conclusion"),
|
||||
("bla bla\nune\tconclusion", "une conclusion"),
|
||||
# No echo.
|
||||
("texte quelconque sans rapport", "une conclusion"),
|
||||
# Partial echo must NOT match.
|
||||
("bla une conclu", "une conclusion"),
|
||||
# The buffer is exactly the echo.
|
||||
("une conclusion", "une conclusion"),
|
||||
# Empty / None inputs.
|
||||
("", "une conclusion"),
|
||||
("bla bla", ""),
|
||||
("", ""),
|
||||
(None, "x"),
|
||||
("x", None),
|
||||
# Whitespace around the cut point.
|
||||
("bla bla une conclusion ", "une conclusion"),
|
||||
("bla bla \n une conclusion", "une conclusion"),
|
||||
# Unicode, accents, emoji, exotic whitespace.
|
||||
("préambule ✅ conclusion émise", "conclusion émise"),
|
||||
("texte 🟢 Réponse", "🟢 Réponse"),
|
||||
("bla\u00a0bla une conclusion", "une\u00a0conclusion"),
|
||||
("bla\u2028une conclusion", "une conclusion"),
|
||||
# Whitespace-only buffer.
|
||||
(" ", "une conclusion"),
|
||||
# Repetitions: the leftmost cut point is contractual.
|
||||
("abc abc abc", "abc"),
|
||||
("abc abc abc", "abc abc"),
|
||||
("aaaa", "aa"),
|
||||
]
|
||||
rnd = random.Random(20260920)
|
||||
alphabet = string.ascii_letters + " \n\t" + "éà✅\u00a0"
|
||||
for _ in range(2000):
|
||||
base = "".join(rnd.choice(alphabet) for _ in range(rnd.randint(0, 120)))
|
||||
if rnd.random() < 0.5 and len(base) > 4:
|
||||
k = rnd.randint(1, max(1, len(base) // 2))
|
||||
suffix = base[-k:]
|
||||
else:
|
||||
suffix = "".join(rnd.choice(alphabet) for _ in range(rnd.randint(0, 30)))
|
||||
cases.append((base, suffix))
|
||||
return cases
|
||||
|
||||
|
||||
def test_index_matches_raw_reference_oracle():
|
||||
"""Incremental index == naive raw probe, for one-shot and chunked appends."""
|
||||
import api.streaming as streaming
|
||||
|
||||
divergences = []
|
||||
for value, suffix in _cases():
|
||||
expected = _raw_reference(value, suffix)
|
||||
index = streaming._CompactEchoIndex()
|
||||
index.append(value)
|
||||
actual = index.cut_to(suffix)
|
||||
if actual is None:
|
||||
got = (str(value or ''), False)
|
||||
else:
|
||||
got = (str(value or '')[:actual].rstrip(), True)
|
||||
if got != expected:
|
||||
divergences.append((value, suffix, expected, got))
|
||||
|
||||
assert not divergences[:5], (
|
||||
f"{len(divergences)} divergence(s) from the reference implementation; "
|
||||
f"first: {divergences[:1]}"
|
||||
)
|
||||
|
||||
|
||||
def test_chunked_appends_reach_the_same_cut_point():
|
||||
"""Appending in pieces must equal appending the whole string."""
|
||||
import api.streaming as streaming
|
||||
|
||||
rnd = random.Random(99)
|
||||
for _ in range(500):
|
||||
parts = [
|
||||
"".join(
|
||||
rnd.choice(string.ascii_letters + " \n\té")
|
||||
for _ in range(rnd.randint(1, 20))
|
||||
)
|
||||
for _ in range(rnd.randint(1, 8))
|
||||
]
|
||||
value = "".join(parts)
|
||||
if rnd.random() < 0.6 and len(value) > 4:
|
||||
suffix = value[-rnd.randint(1, len(value) // 2):]
|
||||
else:
|
||||
suffix = "".join(rnd.choice("abc \t") for _ in range(rnd.randint(0, 12)))
|
||||
|
||||
chunked = streaming._CompactEchoIndex()
|
||||
for part in parts:
|
||||
chunked.append(part)
|
||||
whole = streaming._CompactEchoIndex()
|
||||
whole.append(value)
|
||||
|
||||
assert chunked.cut_to(suffix) == whole.cut_to(suffix), (
|
||||
f"chunked append diverged for value={value!r} suffix={suffix!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_matches_tail_and_cut_to_agree():
|
||||
"""The boolean probe and the cut probe must never disagree."""
|
||||
import api.streaming as streaming
|
||||
|
||||
for value, suffix in _cases():
|
||||
index = streaming._CompactEchoIndex()
|
||||
index.append(value)
|
||||
assert index.matches_tail(suffix) == (index.cut_to(suffix) is not None), (
|
||||
f"matches_tail/cut_to disagree for value={value!r} suffix={suffix!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_reset_clears_state_after_truncation():
|
||||
"""After reset + re-append the index must describe only the new text."""
|
||||
import api.streaming as streaming
|
||||
|
||||
index = streaming._CompactEchoIndex()
|
||||
index.append("old reasoning tail with a wide span " + " " * 5000)
|
||||
index.reset()
|
||||
index.append("fresh start only")
|
||||
|
||||
assert index.compact_length == len("freshstartonly")
|
||||
assert index.cut_to("start only") == len("fresh ")
|
||||
assert index.cut_to("old reasoning") is None
|
||||
|
||||
|
||||
def test_index_probe_does_not_rescan_a_wide_whitespace_span(monkeypatch):
|
||||
"""The probe must not walk raw characters it already folded.
|
||||
|
||||
Pins the cost property the raw backward walk violated: after a 1 MB
|
||||
whitespace span is appended, a probe must cost O(candidate), not
|
||||
O(span). Counting ``str.isspace``-adjacent raw access is impractical, so
|
||||
this counts how many characters ``append`` has to fold: exactly the
|
||||
appended length, once — and the probe itself must not fold anything.
|
||||
"""
|
||||
import api.streaming as streaming
|
||||
|
||||
fold_calls = {'n': 0}
|
||||
original = streaming._compact_for_echo_compare
|
||||
|
||||
def counting(value):
|
||||
fold_calls['n'] += 1
|
||||
return original(value)
|
||||
|
||||
monkeypatch.setattr(streaming, '_compact_for_echo_compare', counting)
|
||||
|
||||
index = streaming._CompactEchoIndex()
|
||||
index.append("alpha" + " " * 100_000)
|
||||
fold_calls['n'] = 0
|
||||
|
||||
# 20 probes of a short candidate: the raw walk would fold/walk the span
|
||||
# each time; the index folds only the candidate.
|
||||
for i in range(20):
|
||||
assert index.matches_tail(f"progress {i}") is False
|
||||
|
||||
assert fold_calls['n'] <= 20, (
|
||||
f"echo probe folded {fold_calls['n']} times — it is rescanning the "
|
||||
"raw transcript instead of using the indexed tail"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(60)
|
||||
def test_index_stays_cheap_on_a_wide_span_with_many_probes():
|
||||
"""Wide whitespace span + many probes must not cost seconds of CPU."""
|
||||
import time
|
||||
|
||||
import api.streaming as streaming
|
||||
|
||||
index = streaming._CompactEchoIndex()
|
||||
index.append("alpha" + " " * 1_000_000)
|
||||
probes = [f"progress {i}" for i in range(200)]
|
||||
|
||||
start = time.perf_counter()
|
||||
for probe in probes:
|
||||
index.matches_tail(probe)
|
||||
index.cut_to(probe)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# The raw backward walk needs ~0.05s per probe here (2M+ character walks
|
||||
# total). A generous budget still fails it by an order of magnitude.
|
||||
assert elapsed < 0.5, f"200 probes on a 1MB span took {elapsed:.3f}s"
|
||||
@@ -7,11 +7,16 @@ with ``window x suffix`` instead of with the data actually inspected. On a
|
||||
6000-character conclusion block that is seconds of pure CPU, held under the
|
||||
GIL, which stalls every other stream in the process.
|
||||
|
||||
The current implementation walks ``value`` and ``suffix`` backwards from the
|
||||
end (skipping whitespace in ``value``) and has no fixed search window, so a
|
||||
compact-equivalent echo is found regardless of how much interior whitespace
|
||||
stretches its raw span.
|
||||
|
||||
These tests pin both halves of the contract:
|
||||
|
||||
* the fast path must return byte-identical results to a straightforward
|
||||
reference implementation, including the leftmost-cut tie-break; and
|
||||
* it must not re-fold the search window once per candidate cut index.
|
||||
* it must not fold the whole buffer once per candidate cut index.
|
||||
"""
|
||||
import random
|
||||
import re
|
||||
@@ -20,11 +25,12 @@ import string
|
||||
import pytest
|
||||
|
||||
|
||||
def _reference_strip_compact_echo_suffix(value, suffix, *, search_window: int = 4096):
|
||||
"""Deliberately naive oracle: probe every cut index, fold the whole tail.
|
||||
def _reference_strip_compact_echo_suffix(value, suffix):
|
||||
"""Deliberately naive oracle: probe every cut index, fold the tail.
|
||||
|
||||
This mirrors the original behaviour exactly and exists only so the
|
||||
optimised implementation can be proven equivalent to it.
|
||||
This mirrors the pre-window semantics (the original probing loop) with no
|
||||
search window, and exists only so the optimised backward walk can be
|
||||
proven equivalent to it.
|
||||
"""
|
||||
def compact(v):
|
||||
return re.sub(r'\s+', '', str(v or ''))
|
||||
@@ -33,11 +39,9 @@ def _reference_strip_compact_echo_suffix(value, suffix, *, search_window: int =
|
||||
candidate = compact(suffix)
|
||||
if not raw or not candidate:
|
||||
return raw, False
|
||||
tail = raw[-max(len(str(suffix or '')) * 3, search_window):]
|
||||
offset = len(raw) - len(tail)
|
||||
for idx in range(len(tail) + 1):
|
||||
if compact(tail[idx:]) == candidate:
|
||||
return raw[: offset + idx].rstrip(), True
|
||||
for idx in range(len(raw) + 1):
|
||||
if compact(raw[idx:]) == candidate:
|
||||
return raw[:idx].rstrip(), True
|
||||
return raw, False
|
||||
|
||||
|
||||
@@ -74,8 +78,8 @@ def _cases():
|
||||
("aaaa", "aa"),
|
||||
# Long buffer with the echo at the very end.
|
||||
("remplissage " * 500 + "la vraie conclusion", "la vraie conclusion"),
|
||||
# Echo whose whitespace-padded span is wider than the search window:
|
||||
# the window only exposes ``conclusion``, so nothing may be stripped.
|
||||
# Echo whose whitespace-padded span is wider than any fixed fold
|
||||
# window: the backward walk must still strip it.
|
||||
("bla une" + " " * 5000 + "conclusion", "une conclusion"),
|
||||
# Exotic whitespace that ``\s`` and ``str.isspace`` must treat alike.
|
||||
("bla\u00a0bla une conclusion", "une\u00a0conclusion"),
|
||||
@@ -111,45 +115,29 @@ def test_strip_compact_echo_suffix_matches_reference_oracle():
|
||||
)
|
||||
|
||||
|
||||
def test_strip_compact_echo_suffix_honours_a_custom_search_window():
|
||||
"""The bounded window must keep its meaning on the fast path."""
|
||||
import api.streaming as streaming
|
||||
|
||||
buffer_text = "z" * 400 + " la conclusion"
|
||||
for window in (16, 64, 512, 4096):
|
||||
assert streaming._strip_compact_echo_suffix(
|
||||
buffer_text, "la conclusion", search_window=window
|
||||
) == _reference_strip_compact_echo_suffix(
|
||||
buffer_text, "la conclusion", search_window=window
|
||||
), f"window={window}"
|
||||
|
||||
|
||||
def test_strip_compact_echo_suffix_rejects_an_echo_wider_than_the_window():
|
||||
"""An echo that only fits inside a wider window must not be found.
|
||||
def test_strip_compact_echo_suffix_finds_an_echo_wider_than_any_fixed_window():
|
||||
"""An echo stretched past any fold window must still be stripped.
|
||||
|
||||
The buffer ends with the echo, but interior whitespace stretches its raw
|
||||
span past the default window, so the folded window only exposes the last
|
||||
word. Widening the window to cover the whole span makes the same echo
|
||||
visible again, which proves the non-match is caused by the window and
|
||||
not by the text.
|
||||
span far beyond a fixed fold window (the retired windowed implementation
|
||||
exposed only the last word and left the buffer untouched). The backward
|
||||
walk has no window, so the cut point is found and the echo is removed.
|
||||
"""
|
||||
import api.streaming as streaming
|
||||
|
||||
buffer_text = "bla une" + " " * 5000 + "conclusion"
|
||||
suffix = "une conclusion"
|
||||
|
||||
for impl in (streaming._strip_compact_echo_suffix, _reference_strip_compact_echo_suffix):
|
||||
assert impl(buffer_text, suffix) == (buffer_text, False), impl.__name__
|
||||
assert impl(buffer_text, suffix, search_window=len(buffer_text)) == ("bla", True), impl.__name__
|
||||
assert streaming._strip_compact_echo_suffix(buffer_text, suffix) == ("bla", True)
|
||||
assert _reference_strip_compact_echo_suffix(buffer_text, suffix) == ("bla", True)
|
||||
|
||||
|
||||
def test_strip_compact_echo_suffix_does_not_refold_the_window_per_cut(monkeypatch):
|
||||
def test_strip_compact_echo_suffix_does_not_refold_per_cut(monkeypatch):
|
||||
"""One pass, not one fold per candidate cut index.
|
||||
|
||||
The original implementation called the whitespace-folding helper once per
|
||||
possible cut position — roughly 4097 times for a default window — and each
|
||||
of those calls re-scanned the remaining tail. Counting the calls pins the
|
||||
algorithmic property directly, without depending on wall-clock timing.
|
||||
Counting the folding-helper calls pins the algorithmic property directly,
|
||||
without depending on wall-clock timing: the backward walk folds the
|
||||
suffix once and then compares characters.
|
||||
"""
|
||||
import api.streaming as streaming
|
||||
|
||||
@@ -168,7 +156,7 @@ def test_strip_compact_echo_suffix_does_not_refold_the_window_per_cut(monkeypatc
|
||||
|
||||
assert calls['n'] <= 8, (
|
||||
f"whitespace folding ran {calls['n']} times for a single call; the scan "
|
||||
"is re-folding the window once per candidate cut index"
|
||||
"is re-folding once per candidate cut index"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ _REAL_FUNCTIONS = (
|
||||
"_insertPreservedCompressionTaskFallback",
|
||||
"_pinCompactionCardAtTop",
|
||||
"_pinSettledCompressionReferenceAtTop",
|
||||
# #2051: renderMessages() inserts a message block through this helper, so it has to be
|
||||
# evaluated with it. The shim's createElement() returns no template `content`, so the
|
||||
# helper takes its insertAdjacentHTML fallback here, exactly as before.
|
||||
"_insertSegmentBlock",
|
||||
"renderMessages",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""A stale WebUI sidecar must not hide a durable compression continuation."""
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes, profiles
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lineage(tmp_path, monkeypatch):
|
||||
state = pytest.importorskip("hermes_state")
|
||||
# Worker tests restore sys.path after loading Agent lazily. Scope its
|
||||
# sibling-module lookup to this fixture, never the global test importer.
|
||||
monkeypatch.syspath_prepend(str(Path(state.__file__).parent))
|
||||
SessionDB = state.SessionDB
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session("sealedparent", source="webui")
|
||||
db.create_session("idlechild", source="tui", parent_session_id="sealedparent")
|
||||
db.append_message("idlechild", "user", "retained task")
|
||||
db.end_session("sealedparent", "compression")
|
||||
db.end_session("idlechild", "idle_timeout")
|
||||
monkeypatch.setattr(profiles, "_resolve_profile_home_for_name", lambda name: str(tmp_path))
|
||||
session = SimpleNamespace(session_id="sealedparent", profile="default", pre_compression_snapshot=False)
|
||||
yield db, session
|
||||
db.close()
|
||||
|
||||
|
||||
def test_reload_resolves_sqlite_rotation_without_sidecar_flag(lineage):
|
||||
db, session = lineage
|
||||
before = db.get_session("idlechild")["ended_at"]
|
||||
assert routes._pre_compression_continuation_session_id(session) == "idlechild"
|
||||
assert db.get_session("idlechild")["ended_at"] == before
|
||||
assert db.get_session("sealedparent")["end_reason"] == "compression"
|
||||
|
||||
|
||||
class Handler:
|
||||
headers = {}
|
||||
def __init__(self):
|
||||
self.wfile = io.BytesIO()
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
def send_header(self, *args):
|
||||
pass
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _assert_stale_post_rotation(session, monkeypatch, expected_continuation):
|
||||
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **kw: None)
|
||||
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda *a, **kw: session)
|
||||
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *a: True)
|
||||
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *a: pytest.fail("stale turn must not reach workspace mutation"))
|
||||
h = Handler()
|
||||
routes._handle_chat_start(h, {"session_id": session.session_id, "message": "conclusion?"})
|
||||
assert h.status == 409
|
||||
payload = json.loads(h.wfile.getvalue())
|
||||
assert payload["code"] == "session_rotated"
|
||||
assert payload["continuation_session_id"] == expected_continuation
|
||||
|
||||
|
||||
def test_stale_post_rejected_before_workspace_or_worker_mutation(lineage, monkeypatch):
|
||||
_, session = lineage
|
||||
_assert_stale_post_rotation(session, monkeypatch, "idlechild")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["desktop", "acp"])
|
||||
def test_same_source_local_interactive_lineage_resumes(lineage, monkeypatch, source):
|
||||
db, session = lineage
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET source=? WHERE id IN ('sealedparent', 'idlechild')",
|
||||
(source,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
assert routes._pre_compression_continuation_session_id(session) == "idlechild"
|
||||
_assert_stale_post_rotation(session, monkeypatch, "idlechild")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
["cron", "webhook", "kanban", "tool", "subagent", "unknown-source"],
|
||||
)
|
||||
def test_noninteractive_lineage_is_not_resumable(lineage, monkeypatch, source):
|
||||
db, session = lineage
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET source=? WHERE id IN ('sealedparent', 'idlechild')",
|
||||
(source,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
assert routes._pre_compression_continuation_session_id(session) is None
|
||||
_assert_stale_post_rotation(session, monkeypatch, None)
|
||||
|
||||
|
||||
def test_idle_tip_accepts_normal_persistence_without_reopening_parent(lineage):
|
||||
db, session = lineage
|
||||
tip = routes._pre_compression_continuation_session_id(session)
|
||||
db.append_message(tip, "user", "resume task")
|
||||
assert db.get_session("sealedparent")["end_reason"] == "compression"
|
||||
assert db.get_messages(tip)[-1]["content"] == "resume task"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", ["reset", "user_closed", "compression"])
|
||||
@pytest.mark.parametrize("ended_at", [None, 1234.0])
|
||||
def test_unknown_or_sealed_terminal_tip_is_not_redirected(lineage, reason, ended_at):
|
||||
db, session = lineage
|
||||
db._conn.execute("UPDATE sessions SET end_reason=?, ended_at=? WHERE id='idlechild'", (reason, ended_at))
|
||||
db._conn.commit()
|
||||
assert routes._pre_compression_continuation_session_id(session) is None
|
||||
|
||||
|
||||
def test_newer_branch_does_not_replace_canonical_continuation(lineage):
|
||||
db, session = lineage
|
||||
db.create_session("branchchild", source="webui", parent_session_id="sealedparent")
|
||||
db._conn.execute("UPDATE sessions SET message_count=20, model_config=? WHERE id='branchchild'", (json.dumps({'_branched_from': 'sealedparent'}),))
|
||||
db._conn.commit()
|
||||
assert routes._pre_compression_continuation_session_id(session) == "idlechild"
|
||||
|
||||
|
||||
def test_foreign_profile_and_subagent_are_not_redirected(lineage):
|
||||
db, session = lineage
|
||||
db._conn.execute("UPDATE sessions SET profile_name='foreign' WHERE id='idlechild'")
|
||||
db._conn.commit()
|
||||
assert routes._pre_compression_continuation_session_id(session) is None
|
||||
db._conn.execute("UPDATE sessions SET profile_name=NULL, source='subagent' WHERE id='idlechild'")
|
||||
db._conn.commit()
|
||||
assert routes._pre_compression_continuation_session_id(session) is None
|
||||
|
||||
|
||||
def test_noncompressed_parent_and_absent_database_keep_legacy_behavior(lineage, monkeypatch, tmp_path):
|
||||
db, session = lineage
|
||||
db._conn.execute("UPDATE sessions SET end_reason='idle_timeout' WHERE id='sealedparent'")
|
||||
db._conn.commit()
|
||||
assert routes._pre_compression_continuation_session_id(session) is None
|
||||
monkeypatch.setattr(profiles, "_resolve_profile_home_for_name", lambda name: str(tmp_path / 'missing'))
|
||||
assert routes._pre_compression_continuation_session_id(session) is None
|
||||
assert not (tmp_path / 'missing').exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method', ['get_session', 'get_compression_tip'])
|
||||
@pytest.mark.parametrize('kind', ['missing', 'noncallable', 'signature'])
|
||||
def test_older_agent_keeps_sidecar_recovery(tmp_path, monkeypatch, method, kind):
|
||||
import sys
|
||||
from api.compression_continuation import durable_compression_continuation
|
||||
|
||||
class OldDB:
|
||||
closed = False
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
def get_session(self, sid):
|
||||
return {'end_reason': 'compression'}
|
||||
def get_compression_tip(self, sid):
|
||||
return 'legacychild'
|
||||
def close(self):
|
||||
OldDB.closed = True
|
||||
|
||||
if kind == 'missing':
|
||||
delattr(OldDB, method)
|
||||
elif kind == 'noncallable':
|
||||
setattr(OldDB, method, None)
|
||||
else:
|
||||
setattr(OldDB, method, lambda self, sid, required: None)
|
||||
monkeypatch.setitem(sys.modules, 'hermes_state', SimpleNamespace(SessionDB=OldDB))
|
||||
(tmp_path / 'state.db').touch()
|
||||
monkeypatch.setattr(profiles, '_resolve_profile_home_for_name', lambda _: str(tmp_path))
|
||||
session = SimpleNamespace(session_id='legacyparent', profile='default', pre_compression_snapshot=True)
|
||||
child = SimpleNamespace(session_id='legacychild', profile='default',
|
||||
parent_session_id='legacyparent', pre_compression_snapshot=False,
|
||||
updated_at=2, created_at=1)
|
||||
monkeypatch.setattr(routes, 'SESSIONS', {'legacychild': child})
|
||||
monkeypatch.setattr(routes, 'SESSION_DIR', tmp_path)
|
||||
assert durable_compression_continuation(session) == (False, None)
|
||||
assert OldDB.closed
|
||||
assert routes._pre_compression_continuation_session_id(session) == 'legacychild'
|
||||
|
||||
|
||||
def test_browser_rotation_restores_draft_without_reposting():
|
||||
source = (Path(__file__).parents[1] / 'static/messages.js').read_text()
|
||||
helper = source[source.index('async function _recoverCompressedSend('):source.index('function _restoreComposerDraftAfterFailedSend(')]
|
||||
script = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
let S={session:{session_id:'old'}}, INFLIGHT={old:{}};
|
||||
const calls=[];
|
||||
const stopApprovalPolling=()=>{},stopClarifyPolling=()=>{},removeThinking=()=>{},setBusy=()=>{},setComposerStatus=()=>{},showToast=()=>{};
|
||||
const loadSession=async sid=>{calls.push(['load',sid]);S.session={session_id:sid}};
|
||||
const _restoreComposerDraftAfterFailedSend=(...args)=>calls.push(['restore',...args]);
|
||||
const api=()=>{throw Error('must not repost')};
|
||||
(async()=>{
|
||||
const err={status:409,body:JSON.stringify({code:'session_rotated',continuation_session_id:'new'})};
|
||||
const files=[{name:'drawing.png'}], promise=Promise.resolve();
|
||||
assert.equal(await _recoverCompressedSend(err,'old','conclusion?',files,promise),true);
|
||||
assert.deepEqual(calls[0],['load','new']);
|
||||
assert.deepEqual(calls[1],['restore','conclusion?',files,'new',promise]);
|
||||
assert.equal(INFLIGHT.old,undefined);
|
||||
assert.equal(await _recoverCompressedSend({status:500},'new','x',[],promise),false);
|
||||
assert.equal(await _recoverCompressedSend(err,'old','x',[],promise),false);
|
||||
assert.equal(calls.length,2);
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
'''
|
||||
subprocess.run(['node', '-e', helper + script], check=True, timeout=15)
|
||||
|
||||
|
||||
def test_background_failed_draft_preserves_attachments_after_navigation_race():
|
||||
source = (Path(__file__).parents[1] / 'static/messages.js').read_text()
|
||||
helper = source[source.index('function _restoreComposerDraftAfterFailedSend('):source.index('async function send(){')]
|
||||
script = r'''
|
||||
const assert=require('node:assert/strict');
|
||||
const S={session:{session_id:'unrelated'},pendingFiles:[]};
|
||||
const $=()=>{throw Error('unrelated visible composer must not be touched')};
|
||||
const calls=[];
|
||||
const _saveComposerDraftNow=(...args)=>calls.push(args);
|
||||
(async()=>{
|
||||
const files=[{name:'drawing.png',path:'/uploads/drawing.png'}];
|
||||
_restoreComposerDraftAfterFailedSend('retained',files,'continuation',Promise.resolve());
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls,[['continuation','retained',files]]);
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
'''
|
||||
subprocess.run(['node','-e',helper+script], check=True, timeout=15)
|
||||
@@ -10,6 +10,8 @@ import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.gateway_chat import _gateway_runs_approval_event
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
@@ -873,3 +875,346 @@ def test_legacy_approval_without_run_id_retires_locally():
|
||||
r._pending.pop(session_id, None)
|
||||
r._gateway_queues.pop(session_id, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
|
||||
|
||||
def test_route_deny_settles_exact_non_head_run_producer():
|
||||
"""The response route must settle the exact producer whose card was denied.
|
||||
|
||||
The route is the only caller that supplies a target's ``(run_id,
|
||||
approval_id)`` to the legacy resolver, so a regression in that wiring is
|
||||
invisible to helper-level coverage: the producer would be dropped from the
|
||||
queue without its waiter ever being woken, leaving the agent thread parked
|
||||
until the approval timeout.
|
||||
|
||||
Unlike the route-level test in ``test_approval_unblock.py`` (which needs the
|
||||
installed agent's ``tools.approval`` and is skipped without it), this one
|
||||
runs wherever the suite runs.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from api import route_approvals as ra
|
||||
from api import routes as r
|
||||
|
||||
sid = "sess-route-non-head-deny"
|
||||
run_id = "run-route-non-head-deny"
|
||||
head_id = "approval-route-head"
|
||||
target_id = "approval-route-target"
|
||||
sibling_id = "approval-route-sibling"
|
||||
other_run_id = "run-route-unrelated"
|
||||
head = {
|
||||
"approval_id": head_id,
|
||||
"run_id": run_id,
|
||||
"command": "echo head",
|
||||
"description": "Head approval on the run",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
sibling = {
|
||||
"approval_id": sibling_id,
|
||||
"run_id": run_id,
|
||||
"command": "echo sibling",
|
||||
"description": "Non-head sibling approval on the same run",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
target = {
|
||||
"approval_id": target_id,
|
||||
"run_id": run_id,
|
||||
"command": "echo target",
|
||||
"description": "Non-head approval on the same run",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
other_run = {
|
||||
"approval_id": "approval-route-other",
|
||||
"run_id": other_run_id,
|
||||
"command": "echo else",
|
||||
"description": "Approval on an unrelated run",
|
||||
}
|
||||
|
||||
def producer(payload):
|
||||
"""The agent's real producer entry when installed, an equivalent one otherwise.
|
||||
|
||||
The suite runs without hermes-agent in CI, where
|
||||
``tools.approval._ApprovalEntry`` does not exist; the stand-in carries the
|
||||
same ``data`` / ``event`` / ``result`` / ``reason`` contract the resolution
|
||||
path touches, so the assertions below mean the same thing in both shapes.
|
||||
"""
|
||||
try:
|
||||
from tools.approval import _ApprovalEntry
|
||||
except Exception:
|
||||
_ApprovalEntry = None
|
||||
if _ApprovalEntry is not None:
|
||||
return _ApprovalEntry(dict(payload))
|
||||
return SimpleNamespace(
|
||||
data=dict(payload), event=threading.Event(), result=None, reason=None
|
||||
)
|
||||
|
||||
head_entry = producer(head)
|
||||
sibling_entry = producer(sibling)
|
||||
target_entry = producer(target)
|
||||
other_run_entry = producer(other_run)
|
||||
relayed = []
|
||||
captured = {}
|
||||
|
||||
def fake_j(_handler, data, status=200, extra_headers=None):
|
||||
captured.update(payload=data, status=status)
|
||||
return data
|
||||
|
||||
def fake_respond(_self, got_run_id, got_approval_id, choice):
|
||||
relayed.append((got_run_id, got_approval_id, choice))
|
||||
return {"resolved": 1}
|
||||
|
||||
try:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues[sid] = [head_entry, sibling_entry, target_entry, other_run_entry]
|
||||
ra.submit_gateway_pending_mirror(sid, dict(target_entry.data))
|
||||
mirror = ra.gateway_pending_mirror(sid, approval_id=target_id, run_id=run_id)
|
||||
assert mirror is not None, "the non-head approval must be mirrored before it is answered"
|
||||
|
||||
with patch("api.routes.j", new=fake_j), \
|
||||
patch("api.runner_client.HttpRunnerClient.respond_approval", new=fake_respond), \
|
||||
patch("api.config.gateway_supports_approval_identity_v1", return_value=True):
|
||||
r._handle_approval_respond(
|
||||
object(),
|
||||
{
|
||||
"session_id": sid,
|
||||
"choice": "deny",
|
||||
"approval_id": target_id,
|
||||
"run_id": run_id,
|
||||
"mirror_token": mirror[ra._GATEWAY_MIRROR_TOKEN],
|
||||
},
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"payload": {"ok": True, "choice": "deny", "relayed": True},
|
||||
"status": 200,
|
||||
}
|
||||
assert relayed == [(run_id, target_id, "deny")]
|
||||
|
||||
assert target_entry.event.is_set(), "the denied approval's waiter must be woken"
|
||||
assert target_entry.result == "deny"
|
||||
with ra._lock:
|
||||
remaining = list(ra._gateway_queues.get(sid) or [])
|
||||
assert target_entry not in remaining, "the denied approval's producer must be consumed"
|
||||
assert head_entry in remaining, (
|
||||
"the run's own head approval must not be consumed by answering another approval"
|
||||
)
|
||||
assert not head_entry.event.is_set()
|
||||
assert sibling_entry in remaining, (
|
||||
"a sibling approval on the same run must not be consumed"
|
||||
)
|
||||
assert not sibling_entry.event.is_set()
|
||||
assert other_run_entry in remaining, (
|
||||
"an unrelated run's producer must not be consumed"
|
||||
)
|
||||
assert not other_run_entry.event.is_set()
|
||||
|
||||
# These are the observations made by subsequent HTTP polling.
|
||||
for _ in range(2):
|
||||
with ra._lock:
|
||||
ra.reconcile_gateway_pending_mirror_locked(sid)
|
||||
assert ra.gateway_pending_mirror(
|
||||
sid, approval_id=target_id, run_id=run_id
|
||||
) is None, "reconciliation must not resurrect the denied approval's mirror"
|
||||
finally:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues.pop(sid, None)
|
||||
|
||||
|
||||
def test_deny_settles_run_producer_so_reconciliation_cannot_resurrect_mirror():
|
||||
"""Deny must settle the producer as well as its WebUI mirror.
|
||||
|
||||
Selection is the behaviour under test, so the queue carries more than the
|
||||
one entry being resolved: a sibling approval for the same run, and a producer
|
||||
belonging to a different run. Resolving one approval must consume exactly
|
||||
that producer, wake its waiter, and leave both others queued.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from api import route_approvals as ra
|
||||
|
||||
sid = "sess-deny-no-resurrection"
|
||||
approval_id = "approval-deny-no-resurrection"
|
||||
run_id = "run-deny-no-resurrection"
|
||||
sibling_approval_id = "approval-deny-sibling"
|
||||
other_run_id = "run-deny-unrelated"
|
||||
approval = {
|
||||
"approval_id": approval_id,
|
||||
"run_id": run_id,
|
||||
"command": "echo pricing",
|
||||
"description": "Run pricing probe",
|
||||
}
|
||||
sibling_approval = {
|
||||
"approval_id": sibling_approval_id,
|
||||
"run_id": run_id,
|
||||
"command": "echo sibling",
|
||||
"description": "Sibling approval on the same run",
|
||||
}
|
||||
other_run_approval = {
|
||||
"approval_id": "approval-deny-other-run",
|
||||
"run_id": other_run_id,
|
||||
"command": "echo else",
|
||||
"description": "Approval on an unrelated run",
|
||||
}
|
||||
|
||||
def producer(payload):
|
||||
return SimpleNamespace(data=dict(payload), event=threading.Event(), result=None)
|
||||
|
||||
target = producer(approval)
|
||||
same_run_sibling = producer(sibling_approval)
|
||||
other_run = producer(other_run_approval)
|
||||
try:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues[sid] = [target, same_run_sibling, other_run]
|
||||
queued_before = list(ra._gateway_queues[sid])
|
||||
ra.submit_gateway_pending_mirror(sid, dict(approval))
|
||||
mirror = ra.gateway_pending_mirror(sid, approval_id=approval_id, run_id=run_id)
|
||||
assert mirror is not None
|
||||
|
||||
resolved, _head, _total = ra.resolve_gateway_pending_run(
|
||||
sid,
|
||||
approval_id=approval_id,
|
||||
run_id=run_id,
|
||||
choice="deny",
|
||||
)
|
||||
assert resolved == 1
|
||||
|
||||
assert queued_before == [target, same_run_sibling, other_run], (
|
||||
"the queue should have held all three producers before retirement"
|
||||
)
|
||||
with ra._lock:
|
||||
remaining = list(ra._gateway_queues.get(sid) or [])
|
||||
assert target not in remaining, "the denied approval's producer must be consumed"
|
||||
assert target.event.is_set(), "the denied approval's waiter must be woken"
|
||||
assert target.result == "deny"
|
||||
assert same_run_sibling in remaining, (
|
||||
"a sibling approval on the same run must not be consumed by retiring another approval"
|
||||
)
|
||||
assert other_run in remaining, (
|
||||
"an unrelated run's producer must not be consumed"
|
||||
)
|
||||
|
||||
# These are the same observations made by subsequent HTTP polling: the
|
||||
# denied approval must not come back, and the survivors must stay live.
|
||||
# `gateway_pending_mirror` acquires `_lock` itself, so it must be called
|
||||
# outside the locked section.
|
||||
for _ in range(2):
|
||||
with ra._lock:
|
||||
ra.reconcile_gateway_pending_mirror_locked(sid)
|
||||
assert ra.gateway_pending_mirror(
|
||||
sid, approval_id=approval_id, run_id=run_id
|
||||
) is None, "reconciliation must not resurrect the denied approval's mirror"
|
||||
with ra._lock:
|
||||
refreshed = list(ra._gateway_queues.get(sid) or [])
|
||||
assert target not in refreshed
|
||||
assert same_run_sibling in refreshed
|
||||
assert other_run in refreshed
|
||||
finally:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues.pop(sid, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure_point",
|
||||
["reconcile_gateway_pending_mirror_locked", "_approval_sse_notify_locked"],
|
||||
)
|
||||
def test_exact_run_resolution_settles_before_projection_failure(failure_point):
|
||||
"""Projection failures must not strand an already-consumed producer."""
|
||||
from types import SimpleNamespace
|
||||
from api import route_approvals as ra
|
||||
|
||||
sid = f"sess-exact-projection-failure-{failure_point}"
|
||||
run_id = "run-exact-projection-failure"
|
||||
target = SimpleNamespace(
|
||||
data={"run_id": run_id, "approval_id": "approval-target"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
sibling = SimpleNamespace(
|
||||
data={"run_id": run_id, "approval_id": "approval-sibling"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
try:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues[sid] = [target, sibling]
|
||||
|
||||
with patch.object(ra, failure_point, side_effect=RuntimeError("projection failed")):
|
||||
with pytest.raises(RuntimeError, match="projection failed"):
|
||||
ra.resolve_gateway_pending_run(
|
||||
sid,
|
||||
approval_id="approval-target",
|
||||
run_id=run_id,
|
||||
choice="deny",
|
||||
reason="user denied",
|
||||
)
|
||||
|
||||
assert target.result == "deny"
|
||||
assert target.reason == "user denied"
|
||||
assert target.event.is_set(), "the consumed producer must be woken before projection work"
|
||||
assert sibling.result is None
|
||||
assert sibling.reason is None
|
||||
assert not sibling.event.is_set()
|
||||
with ra._lock:
|
||||
assert ra._gateway_queues[sid] == [sibling]
|
||||
finally:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues.pop(sid, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"terminal_reason",
|
||||
[
|
||||
"Gateway run completed before approval resolution",
|
||||
"Gateway run failed before approval resolution",
|
||||
"Gateway run was cancelled before approval resolution",
|
||||
"Gateway run ended during teardown before approval resolution",
|
||||
],
|
||||
)
|
||||
def test_terminal_run_settlement_denies_all_run_producers_and_preserves_other_runs(
|
||||
terminal_reason,
|
||||
):
|
||||
"""Every terminal exit fails closed without consuming another run's producer."""
|
||||
from types import SimpleNamespace
|
||||
from api import route_approvals as ra
|
||||
|
||||
sid = f"sess-terminal-run-{terminal_reason.split()[2]}"
|
||||
run_id = "run-terminal"
|
||||
|
||||
def producer(entry_run_id, approval_id):
|
||||
return SimpleNamespace(
|
||||
data={"run_id": entry_run_id, "approval_id": approval_id},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
targets = [producer(run_id, "approval-a"), producer(run_id, "approval-b")]
|
||||
survivor = producer("run-other", "approval-other")
|
||||
try:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues[sid] = [targets[0], survivor, targets[1]]
|
||||
|
||||
settled, _head, _total = ra.settle_gateway_pending_run(
|
||||
sid, run_id, reason=terminal_reason
|
||||
)
|
||||
|
||||
assert settled == 2
|
||||
for target in targets:
|
||||
assert target.result == "deny"
|
||||
assert target.reason == terminal_reason
|
||||
assert target.event.is_set()
|
||||
assert survivor.result is None
|
||||
assert survivor.reason is None
|
||||
assert not survivor.event.is_set()
|
||||
with ra._lock:
|
||||
assert ra._gateway_queues[sid] == [survivor]
|
||||
finally:
|
||||
with ra._lock:
|
||||
ra._pending.pop(sid, None)
|
||||
ra._gateway_queues.pop(sid, None)
|
||||
@@ -333,6 +333,178 @@ def test_gateway_runs_api_submission():
|
||||
assert captured["body_extras"]["reasoning_effort"] == "high"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("terminal_event", "terminal_reason"),
|
||||
[
|
||||
("run.completed", "Gateway run completed before approval resolution"),
|
||||
("run.failed", "Gateway run failed before approval resolution"),
|
||||
("run.cancelled", "Gateway run was cancelled before approval resolution"),
|
||||
],
|
||||
)
|
||||
def test_gateway_terminal_event_fail_closes_parked_run_producer(
|
||||
terminal_event,
|
||||
terminal_reason,
|
||||
):
|
||||
"""Each terminal gateway event denies and wakes the run's parked producers."""
|
||||
from api import gateway_chat
|
||||
from api import route_approvals as approvals
|
||||
|
||||
sid = f"sess-terminal-event-{terminal_event}"
|
||||
stream_id = f"stream-terminal-event-{terminal_event}"
|
||||
run_id = f"run-terminal-event-{terminal_event}"
|
||||
target = SimpleNamespace(
|
||||
data={"run_id": run_id, "approval_id": "approval-target"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
survivor = SimpleNamespace(
|
||||
data={"run_id": "run-other", "approval_id": "approval-other"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
class JsonResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def read(self, _limit=None):
|
||||
return json.dumps({"run_id": run_id}).encode()
|
||||
|
||||
class SseResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def __iter__(self):
|
||||
payload = {"event": terminal_event}
|
||||
if terminal_event == "run.failed":
|
||||
payload["error"] = "terminal failure"
|
||||
return iter([f"data: {json.dumps(payload)}".encode(), b""])
|
||||
|
||||
def fake_urlopen(req, *, timeout=None):
|
||||
del timeout
|
||||
return JsonResponse() if req.full_url.endswith("/v1/runs") else SseResponse()
|
||||
|
||||
try:
|
||||
with approvals._lock:
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues[sid] = [target, survivor]
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
if terminal_event == "run.failed":
|
||||
with pytest.raises(RuntimeError, match="terminal failure"):
|
||||
gateway_chat._run_gateway_runs_api_streaming(
|
||||
sid, "hi", "test", "/tmp", stream_id, "http://gw:8642", "", [], {},
|
||||
put_gateway_event=lambda *_args: None,
|
||||
cancel_event=threading.Event(),
|
||||
session=SimpleNamespace(context_messages=[]),
|
||||
)
|
||||
else:
|
||||
gateway_chat._run_gateway_runs_api_streaming(
|
||||
sid, "hi", "test", "/tmp", stream_id, "http://gw:8642", "", [], {},
|
||||
put_gateway_event=lambda *_args: None,
|
||||
cancel_event=threading.Event(),
|
||||
session=SimpleNamespace(context_messages=[]),
|
||||
)
|
||||
|
||||
assert target.result == "deny"
|
||||
assert target.reason == terminal_reason
|
||||
assert target.event.is_set()
|
||||
assert survivor.result is None
|
||||
assert not survivor.event.is_set()
|
||||
with approvals._lock:
|
||||
assert approvals._gateway_queues[sid] == [survivor]
|
||||
finally:
|
||||
gateway_chat._STREAM_RUN_IDS.pop(stream_id, None)
|
||||
with approvals._lock:
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues.pop(sid, None)
|
||||
|
||||
|
||||
def test_gateway_stream_teardown_fail_closes_parked_run_producer():
|
||||
"""A disconnect before a terminal event still settles the mapped run."""
|
||||
from api import gateway_chat
|
||||
from api import route_approvals as approvals
|
||||
from api.config import STREAMS, STREAMS_LOCK
|
||||
|
||||
sid = "sess-terminal-teardown"
|
||||
stream_id = "stream-terminal-teardown"
|
||||
run_id = "run-terminal-teardown"
|
||||
target = SimpleNamespace(
|
||||
data={"run_id": run_id, "approval_id": "approval-target"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
survivor = SimpleNamespace(
|
||||
data={"run_id": "run-other", "approval_id": "approval-other"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
events = []
|
||||
queue = MagicMock()
|
||||
queue.put_nowait = lambda item: events.append(item)
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = queue
|
||||
|
||||
session = MagicMock()
|
||||
session.active_stream_id = stream_id
|
||||
session.workspace = "/tmp"
|
||||
session.model = "test"
|
||||
session.model_provider = None
|
||||
session.profile = None
|
||||
session.context_messages = []
|
||||
session.messages = []
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = None
|
||||
session.pending_started_at = None
|
||||
|
||||
def disconnect_after_run_id(*_args, **_kwargs):
|
||||
gateway_chat._publish_gateway_run_id(stream_id, run_id)
|
||||
raise RuntimeError("gateway disconnected")
|
||||
|
||||
try:
|
||||
with approvals._lock:
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues[sid] = [target, survivor]
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"},
|
||||
), patch("api.gateway_chat.gateway_supports_approval", return_value=True), patch(
|
||||
"api.gateway_chat._run_gateway_runs_api_streaming",
|
||||
side_effect=disconnect_after_run_id,
|
||||
), patch("api.gateway_chat.get_session", return_value=session):
|
||||
gateway_chat._run_gateway_chat_streaming(
|
||||
session_id=sid,
|
||||
msg_text="hi",
|
||||
model="test-model",
|
||||
workspace="/tmp",
|
||||
stream_id=stream_id,
|
||||
)
|
||||
|
||||
assert target.result == "deny"
|
||||
assert target.reason == "Gateway run ended during teardown before approval resolution"
|
||||
assert target.event.is_set()
|
||||
assert survivor.result is None
|
||||
assert not survivor.event.is_set()
|
||||
with approvals._lock:
|
||||
assert approvals._gateway_queues[sid] == [survivor]
|
||||
finally:
|
||||
gateway_chat._STREAM_RUN_IDS.pop(stream_id, None)
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop(stream_id, None)
|
||||
with approvals._lock:
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues.pop(sid, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Approval event translation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1268,22 +1440,32 @@ def test_terminal_run_retirement_removes_all_same_run_mirrors():
|
||||
approvals._pending.pop(sid, None)
|
||||
|
||||
|
||||
def test_terminal_run_retirement_clears_same_run_gateway_queue_state():
|
||||
def test_terminal_run_settlement_clears_same_run_gateway_queue_state():
|
||||
import api.route_approvals as approvals
|
||||
|
||||
sid = "sess-terminal-run-queue"
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues.pop(sid, None)
|
||||
try:
|
||||
approvals._gateway_queues[sid] = [
|
||||
SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}),
|
||||
SimpleNamespace(data={"command": "local-head"}),
|
||||
]
|
||||
target = SimpleNamespace(
|
||||
data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
local_head = SimpleNamespace(data={"command": "local-head"})
|
||||
approvals._gateway_queues[sid] = [target, local_head]
|
||||
approvals.submit_gateway_pending_mirror(
|
||||
sid,
|
||||
{"run_id": "run-a", "approval_id": "appr-a", "command": "a"},
|
||||
)
|
||||
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a")
|
||||
settled, _head, _total = approvals.settle_gateway_pending_run(
|
||||
sid, "run-a", reason="terminal"
|
||||
)
|
||||
assert settled == 1
|
||||
assert target.result == "deny"
|
||||
assert target.reason == "terminal"
|
||||
assert target.event.is_set()
|
||||
assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None
|
||||
assert not any(
|
||||
str((getattr(entry, "data", None) or {}).get("run_id") or "").strip() == "run-a"
|
||||
@@ -1294,18 +1476,30 @@ def test_terminal_run_retirement_clears_same_run_gateway_queue_state():
|
||||
approvals._gateway_queues.pop(sid, None)
|
||||
|
||||
|
||||
def test_terminal_run_retirement_clears_non_head_same_run_gateway_queue_state():
|
||||
def test_terminal_run_settlement_clears_non_head_same_run_gateway_queue_state():
|
||||
import api.route_approvals as approvals
|
||||
|
||||
sid = "sess-terminal-run-non-head-queue"
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues.pop(sid, None)
|
||||
try:
|
||||
target = SimpleNamespace(
|
||||
data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
approvals._gateway_queues[sid] = [
|
||||
SimpleNamespace(data={"command": "local-head"}),
|
||||
SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}),
|
||||
target,
|
||||
]
|
||||
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a")
|
||||
settled, _head, _total = approvals.settle_gateway_pending_run(
|
||||
sid, "run-a", reason="terminal"
|
||||
)
|
||||
assert settled == 1
|
||||
assert target.result == "deny"
|
||||
assert target.reason == "terminal"
|
||||
assert target.event.is_set()
|
||||
assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None
|
||||
assert not any(
|
||||
str((getattr(entry, "data", None) or {}).get("run_id") or "").strip() == "run-a"
|
||||
@@ -1865,7 +2059,15 @@ def test_chat_cancel_waits_for_worker_published_run_id_before_settlement(
|
||||
STREAMS[stream_id] = SimpleNamespace(put_nowait=lambda *_args, **_kwargs: None)
|
||||
register_stream_owner(stream_id, sid)
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-stop/1", "command": "first"})
|
||||
approval_entry = SimpleNamespace(
|
||||
data={"run_id": "run-stop/1", "approval_id": "approval-stop"},
|
||||
event=threading.Event(),
|
||||
result=None,
|
||||
reason=None,
|
||||
)
|
||||
with approvals._lock:
|
||||
approvals._gateway_queues[sid] = [approval_entry]
|
||||
approvals.submit_gateway_pending_mirror(sid, approval_entry.data)
|
||||
session = SimpleNamespace(
|
||||
profile=None,
|
||||
workspace="/tmp",
|
||||
@@ -1945,8 +2147,15 @@ def test_chat_cancel_waits_for_worker_published_run_id_before_settlement(
|
||||
assert called["stop"] == "run-stop/1"
|
||||
assert called["cancel"] is expect_cancel
|
||||
if stop_result:
|
||||
assert approval_entry.result == "deny"
|
||||
assert approval_entry.reason == (
|
||||
"Gateway run was cancelled before approval resolution"
|
||||
)
|
||||
assert approval_entry.event.is_set()
|
||||
assert approvals.gateway_pending_mirror(sid, run_id="run-stop/1") is None
|
||||
else:
|
||||
assert approval_entry.result is None
|
||||
assert not approval_entry.event.is_set()
|
||||
assert stream_id in STREAMS
|
||||
assert stream_owner_session_id(stream_id) == sid
|
||||
assert session.active_stream_id == stream_id
|
||||
@@ -1957,7 +2166,9 @@ def test_chat_cancel_waits_for_worker_published_run_id_before_settlement(
|
||||
release_worker.set()
|
||||
request_thread.join(timeout=5)
|
||||
worker_thread.join(timeout=5)
|
||||
with approvals._lock:
|
||||
approvals._pending.pop(sid, None)
|
||||
approvals._gateway_queues.pop(sid, None)
|
||||
STREAMS.pop(stream_id, None)
|
||||
ACTIVE_RUNS.pop(stream_id, None)
|
||||
unregister_stream_owner(stream_id)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Regression: GatewayWatcher must not burn CPU when nobody is listening.
|
||||
|
||||
Maint #3035 / production evidence: with zero inbound traffic the daemon still
|
||||
re-fingerprinted every sessions row every 5s and woke 10×/s on ``time.sleep(0.1)``
|
||||
stop-checks, holding ~7% of a core forever.
|
||||
|
||||
The poll loop must:
|
||||
1. park (no fingerprint / no projection) while subscriber count is zero, and
|
||||
2. wait on ``threading.Event.wait`` for the poll interval — not a spin of
|
||||
short sleeps — so a quiet process costs essentially nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _make_db(tmp_path: Path):
|
||||
db = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
session_source TEXT,
|
||||
model TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
parent_session_id TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
title TEXT,
|
||||
archived INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, model, started_at, message_count, title) "
|
||||
"VALUES ('tg1', 'telegram', 'm', ?, 1, 'Chat')",
|
||||
(time.time(),),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||||
"VALUES ('tg1', 'user', 'x', ?)",
|
||||
(time.time(),),
|
||||
)
|
||||
conn.commit()
|
||||
return db, conn
|
||||
|
||||
|
||||
def test_poll_loop_uses_blocking_event_wait_not_spin_sleep():
|
||||
"""Source contract: the idle sleep must be one Event.wait, not 50× sleep(0.1)."""
|
||||
src = (
|
||||
Path(__file__).resolve().parents[1] / "api" / "gateway_watcher.py"
|
||||
).read_text(encoding="utf-8")
|
||||
start = src.index("def _poll_loop(self)")
|
||||
end = src.index("\n# ── Module-level watcher registry", start)
|
||||
body = src[start:end]
|
||||
# Strip docstrings so a historical mention of the old spin cannot false-fail.
|
||||
code = body
|
||||
while '"""' in code:
|
||||
a = code.index('"""')
|
||||
b = code.index('"""', a + 3) + 3
|
||||
code = code[:a] + code[b:]
|
||||
assert "time.sleep(0.1)" not in code, (
|
||||
"0.1s stop-check spin burns syscalls forever; use Event.wait(POLL_INTERVAL)"
|
||||
)
|
||||
assert "self._stop_event.wait(" in code, (
|
||||
"poll interval must block on _stop_event.wait so stop() wakes immediately"
|
||||
)
|
||||
assert "_idle_wake" in code and "_has_subscribers" in code, (
|
||||
"zero-subscriber path must park on _idle_wake instead of fingerprinting"
|
||||
)
|
||||
|
||||
|
||||
def test_poll_loop_parks_without_subscribers(tmp_path, monkeypatch):
|
||||
"""Zero subscribers → zero fingerprint work; subscribe wakes and polls resume."""
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
db, _conn = _make_db(tmp_path)
|
||||
|
||||
polls: list[float] = []
|
||||
real_poll_once = gw.GatewayWatcher._poll_once
|
||||
|
||||
def tracing_poll_once(self, *args, **kwargs):
|
||||
polls.append(time.monotonic())
|
||||
return real_poll_once(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gw.GatewayWatcher, "_poll_once", tracing_poll_once)
|
||||
|
||||
watcher = gw.GatewayWatcher(state_db_path=db)
|
||||
# Keep the active interval short so the subscribed phase is fast to assert.
|
||||
watcher.POLL_INTERVAL = 0.05
|
||||
watcher.start()
|
||||
try:
|
||||
time.sleep(0.25)
|
||||
assert polls == [], (
|
||||
"with no SSE subscribers the watcher must not fingerprint/poll state.db; "
|
||||
f"got {len(polls)} poll(s)"
|
||||
)
|
||||
|
||||
q = watcher.subscribe()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not polls and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert polls, "first subscribe must wake the parked poll loop promptly"
|
||||
|
||||
# Let at least one more subscribed cycle land, then detach.
|
||||
before_unsub = len(polls)
|
||||
deadline = time.monotonic() + 2.0
|
||||
while len(polls) <= before_unsub and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(polls) > before_unsub, "subscribed watcher must keep polling"
|
||||
|
||||
watcher.unsubscribe(q)
|
||||
# Drain any in-flight poll that started before unsubscribe, then prove
|
||||
# the loop re-parks (no further growth).
|
||||
time.sleep(watcher.POLL_INTERVAL + 0.05)
|
||||
settled = len(polls)
|
||||
time.sleep(0.3)
|
||||
assert len(polls) == settled, (
|
||||
"after the last subscriber leaves, polling must park again; "
|
||||
f"grew from {settled} to {len(polls)}"
|
||||
)
|
||||
finally:
|
||||
watcher.stop()
|
||||
assert not watcher.is_alive() or (
|
||||
watcher._thread is None or not watcher._thread.is_alive()
|
||||
)
|
||||
|
||||
|
||||
def test_stop_unparks_idle_watcher_promptly(tmp_path):
|
||||
"""stop() must release a watcher parked with no subscribers within join timeout."""
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
db, _conn = _make_db(tmp_path)
|
||||
watcher = gw.GatewayWatcher(state_db_path=db)
|
||||
watcher.POLL_INTERVAL = 30 # would hang badly if stop relied on the interval alone
|
||||
watcher.start()
|
||||
try:
|
||||
# Confirm we are parked (thread alive, no work).
|
||||
assert watcher.is_alive()
|
||||
time.sleep(0.05)
|
||||
t0 = time.monotonic()
|
||||
watcher.stop()
|
||||
elapsed = time.monotonic() - t0
|
||||
assert elapsed < 2.0, f"stop() blocked {elapsed:.2f}s on an idle parked watcher"
|
||||
assert not watcher.is_alive()
|
||||
finally:
|
||||
watcher.stop()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Regression: a re-subscribing SSE client gets a fresh watcher projection.
|
||||
|
||||
``_poll_once`` resets the parity timestamp when it runs with no subscribers
|
||||
("force fresh projection on the next subscription"), but since the idle-park
|
||||
change (#7694) the poll loop parks *before* calling ``_poll_once`` once the
|
||||
last subscriber leaves, so that reset is not reached on a normal disconnect.
|
||||
A tab that reconnects inside the parity interval then resumes with the stale
|
||||
cheap fingerprint and skips the projection. Final removal invalidates the
|
||||
cache, and an epoch fences projections already in flight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_db(tmp_path: Path) -> Path:
|
||||
db = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
session_source TEXT,
|
||||
model TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
parent_session_id TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
title TEXT,
|
||||
archived INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, model, started_at, message_count, title) "
|
||||
"VALUES ('tg1', 'telegram', 'm', ?, 1, 'Chat')",
|
||||
(time.time(),),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tg1', 'user', 'x', ?)",
|
||||
(time.time(),),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db
|
||||
|
||||
|
||||
def test_resubscribe_after_last_unsubscribe_forces_fresh_projection(tmp_path):
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
watcher = gw.GatewayWatcher(state_db_path=_make_db(tmp_path))
|
||||
q = watcher.subscribe()
|
||||
assert watcher._poll_once(now=1.0) is True
|
||||
|
||||
watcher.unsubscribe(q)
|
||||
assert watcher._last_cheap_fp == ""
|
||||
assert watcher._last_full_projection_at is None
|
||||
|
||||
watcher.subscribe()
|
||||
# Well inside PROJECTION_PARITY_INTERVAL and with an unchanged state.db.
|
||||
assert watcher._poll_once(now=2.0) is True
|
||||
|
||||
|
||||
def test_unsubscribe_keeps_fingerprint_while_other_subscribers_remain(tmp_path):
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
watcher = gw.GatewayWatcher(state_db_path=_make_db(tmp_path))
|
||||
first = watcher.subscribe()
|
||||
watcher.subscribe()
|
||||
assert watcher._poll_once(now=1.0) is True
|
||||
fingerprint = watcher._last_cheap_fp
|
||||
assert fingerprint
|
||||
|
||||
watcher.unsubscribe(first)
|
||||
assert watcher._last_cheap_fp == fingerprint
|
||||
assert watcher._last_full_projection_at == 1.0
|
||||
assert watcher._poll_once(now=2.0) is False
|
||||
|
||||
|
||||
def test_unsubscribe_of_unknown_queue_does_not_reset(tmp_path):
|
||||
import queue as _queue
|
||||
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
watcher = gw.GatewayWatcher(state_db_path=_make_db(tmp_path))
|
||||
watcher.subscribe()
|
||||
assert watcher._poll_once(now=1.0) is True
|
||||
fingerprint = watcher._last_cheap_fp
|
||||
|
||||
watcher.unsubscribe(_queue.Queue())
|
||||
assert watcher._last_cheap_fp == fingerprint
|
||||
assert watcher._last_full_projection_at == 1.0
|
||||
|
||||
|
||||
def test_slow_consumer_eviction_invalidates_populated_cache(tmp_path):
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
watcher = gw.GatewayWatcher(state_db_path=_make_db(tmp_path))
|
||||
q = watcher.subscribe()
|
||||
assert watcher._poll_once(now=1.0) is True
|
||||
assert watcher._last_cheap_fp
|
||||
assert watcher._last_full_projection_at == 1.0
|
||||
|
||||
for _ in range(q.maxsize - q.qsize()):
|
||||
q.put_nowait({"type": "existing"})
|
||||
watcher._notify_subscribers([])
|
||||
assert not watcher._has_subscribers()
|
||||
assert watcher._last_cheap_fp == ""
|
||||
assert watcher._last_full_projection_at is None
|
||||
watcher.unsubscribe(q) # The late SSE cleanup cannot undo the reset.
|
||||
watcher.subscribe()
|
||||
assert watcher._poll_once(now=2.0) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("removal", ["unsubscribe", "slow_consumer"])
|
||||
@pytest.mark.parametrize("reconnect_while_blocked", [False, True])
|
||||
def test_inflight_projection_cannot_restore_cache_after_last_removal(
|
||||
tmp_path, monkeypatch, removal, reconnect_while_blocked,
|
||||
):
|
||||
"""The DB read must not serialize final removal or commit across its epoch."""
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
watcher = gw.GatewayWatcher(state_db_path=_make_db(tmp_path))
|
||||
q = watcher.subscribe()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
projections = []
|
||||
errors = []
|
||||
real_projection = gw._get_agent_sessions_from_db
|
||||
|
||||
def blocked_projection(path):
|
||||
projections.append(path)
|
||||
if len(projections) == 1:
|
||||
entered.set()
|
||||
assert release.wait(5), "test never released the DB read"
|
||||
return real_projection(path)
|
||||
|
||||
def poll():
|
||||
try:
|
||||
watcher._poll_once(now=1.0)
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
monkeypatch.setattr(gw, "_get_agent_sessions_from_db", blocked_projection)
|
||||
worker = threading.Thread(target=poll, daemon=True)
|
||||
worker.start()
|
||||
new_q = None
|
||||
try:
|
||||
assert entered.wait(5), "poll never reached the DB read"
|
||||
if removal == "unsubscribe":
|
||||
watcher.unsubscribe(q)
|
||||
else:
|
||||
for _ in range(q.maxsize):
|
||||
q.put_nowait({"type": "existing"})
|
||||
watcher._notify_subscribers([])
|
||||
# Handler teardown of an evicted queue must be harmless.
|
||||
watcher.unsubscribe(q)
|
||||
assert not watcher._has_subscribers()
|
||||
assert watcher._last_cheap_fp == ""
|
||||
assert watcher._last_full_projection_at is None
|
||||
if reconnect_while_blocked:
|
||||
new_q = watcher.subscribe()
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(timeout=5)
|
||||
assert not worker.is_alive(), "poll did not finish"
|
||||
assert not errors, errors
|
||||
assert watcher._last_cheap_fp == ""
|
||||
assert watcher._last_full_projection_at is None
|
||||
if new_q is not None:
|
||||
assert new_q.empty(), "old projection must not notify a new subscriber"
|
||||
else:
|
||||
watcher.subscribe()
|
||||
assert watcher._poll_once(now=2.0) is True
|
||||
assert len(projections) == 2
|
||||
assert watcher._last_cheap_fp
|
||||
assert watcher._last_full_projection_at == 2.0
|
||||
|
||||
|
||||
def test_poll_loop_reprojects_for_reconnecting_subscriber(tmp_path, monkeypatch):
|
||||
"""Through the real loop: disconnect, park, reconnect -> projection runs again."""
|
||||
gw = importlib.import_module("api.gateway_watcher")
|
||||
db = _make_db(tmp_path)
|
||||
projections: list[float] = []
|
||||
real_projection = gw._get_agent_sessions_from_db
|
||||
|
||||
def tracing_projection(path):
|
||||
projections.append(time.monotonic())
|
||||
return real_projection(path)
|
||||
|
||||
monkeypatch.setattr(gw, "_get_agent_sessions_from_db", tracing_projection)
|
||||
watcher = gw.GatewayWatcher(state_db_path=db)
|
||||
watcher.POLL_INTERVAL = 0.05
|
||||
watcher.start()
|
||||
try:
|
||||
q = watcher.subscribe()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not projections and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(projections) == 1
|
||||
|
||||
watcher.unsubscribe(q)
|
||||
time.sleep(watcher.POLL_INTERVAL * 4) # loop parks; no further projection
|
||||
assert len(projections) == 1
|
||||
|
||||
watcher.subscribe()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while len(projections) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(projections) == 2, "reconnecting subscriber must get a fresh projection"
|
||||
finally:
|
||||
watcher.stop()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""A body-bearing GET that fails auth must not poison a keep-alive connection.
|
||||
|
||||
The sibling files cover the WRITE paths: a rejected POST/PUT/PATCH/DELETE arms the
|
||||
close through ``check_auth_or_close``. ``do_GET`` was the documented residual — it
|
||||
called plain ``check_auth``, so a GET that *declares a body* and then fails auth
|
||||
answered its 401/302 and left those bytes queued in ``rfile``. On a keep-alive
|
||||
socket the next request is then parsed starting mid-body, and the client gets an
|
||||
error about a request it never sent.
|
||||
|
||||
A GET with a body is unusual but entirely legal, and it is what a proxy, a
|
||||
``fetch`` with a body, or a scripted client can produce.
|
||||
|
||||
Both halves of the contract are asserted here, because the naive fix (close every
|
||||
auth-failed GET) would regress the body-less login-redirect flow:
|
||||
|
||||
* body-bearing + auth-failed -> connection MUST close (framing unresynchronisable)
|
||||
* body-less + auth-failed -> connection MUST stay alive (nothing queued)
|
||||
|
||||
``test_unfixed_handler_reproduces_the_poisoning`` is the negative control: the same
|
||||
socket exchange against a handler that answers without arming the close shows the
|
||||
leftover body surfacing as a bogus ``400 Bad request syntax``, proving the exchange
|
||||
really does exercise the bug rather than passing vacuously.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BODY = b'{"q": "search"}'
|
||||
|
||||
|
||||
def _get_with_body(path=b"/api/sessions"):
|
||||
return (
|
||||
b"GET " + path + b" HTTP/1.1\r\n"
|
||||
b"Host: localhost\r\n"
|
||||
b"Content-Type: application/json\r\n"
|
||||
b"Content-Length: " + str(len(BODY)).encode() + b"\r\n"
|
||||
b"\r\n" + BODY
|
||||
)
|
||||
|
||||
|
||||
BODYLESS_GET = (
|
||||
b"GET /api/sessions HTTP/1.1\r\n"
|
||||
b"Host: localhost\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
FOLLOWING_GET = b"GET /api/health HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
||||
|
||||
|
||||
def _declares_body(handler):
|
||||
"""The production rule: framing declares a body, so it is queued in rfile."""
|
||||
raw = handler.headers.get("Content-Length")
|
||||
if raw is not None:
|
||||
stripped = raw.strip(" \t")
|
||||
if not (stripped.isascii() and stripped.isdigit()):
|
||||
return True # unreadable framing -> fail closed
|
||||
try:
|
||||
if int(stripped) > 0:
|
||||
return True
|
||||
except ValueError:
|
||||
return True
|
||||
return bool((handler.headers.get("Transfer-Encoding") or "").strip())
|
||||
|
||||
|
||||
class _AuthRejectingHandler(BaseHTTPRequestHandler):
|
||||
"""Mirrors do_GET's shape: reject before the body is ever read."""
|
||||
|
||||
protocol_version = "HTTP/1.1"
|
||||
arm_close = True
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/health":
|
||||
payload = b'{"status": "ok"}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
# Auth failure BEFORE the body is read — the residual's exact shape.
|
||||
if self.arm_close and _declares_body(self):
|
||||
self.close_connection = True
|
||||
payload = b'{"error": "unauthorized"}'
|
||||
self.send_response(401)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
if self.arm_close and _declares_body(self):
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class _UnfixedHandler(_AuthRejectingHandler):
|
||||
arm_close = False
|
||||
|
||||
|
||||
def _exchange(handler_cls, request_bytes):
|
||||
"""Pipeline `request_bytes` + a follow-up GET down ONE socket; return the reply."""
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
with socket.create_connection(srv.server_address, timeout=5) as s:
|
||||
s.sendall(request_bytes + FOLLOWING_GET)
|
||||
s.settimeout(5)
|
||||
chunks = []
|
||||
while True:
|
||||
try:
|
||||
b = s.recv(4096)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not b:
|
||||
break
|
||||
chunks.append(b)
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
def test_body_bearing_get_that_fails_auth_closes_the_connection():
|
||||
raw = _exchange(_AuthRejectingHandler, _get_with_body())
|
||||
assert b"401" in raw, raw[:200]
|
||||
assert b"Connection: close" in raw, f"401 did not advertise close: {raw[:200]}"
|
||||
# The follow-up GET must NEVER be answered on this socket.
|
||||
assert raw.count(b"HTTP/1.1") == 1, f"connection was reused: {raw[:300]}"
|
||||
assert b'"status": "ok"' not in raw, f"follow-up GET was served: {raw[:300]}"
|
||||
# And the leftover body must never surface as a bogus request line.
|
||||
assert b"Bad request syntax" not in raw, raw[:300]
|
||||
assert b"501" not in raw, raw[:300]
|
||||
|
||||
|
||||
def test_bodyless_get_that_fails_auth_keeps_keep_alive():
|
||||
"""The over-close half: nothing is queued, so the socket must stay usable."""
|
||||
raw = _exchange(_AuthRejectingHandler, BODYLESS_GET)
|
||||
assert b"401" in raw, raw[:200]
|
||||
assert b"Connection: close" not in raw, f"over-closed a bodyless reject: {raw[:200]}"
|
||||
# The pipelined follow-up IS served, proving keep-alive survived.
|
||||
assert b'"status": "ok"' in raw, f"keep-alive was dropped: {raw[:300]}"
|
||||
assert raw.count(b"HTTP/1.1") == 2, raw[:300]
|
||||
|
||||
|
||||
def test_unfixed_handler_reproduces_the_poisoning():
|
||||
"""Negative control: without the arming, the leftover body corrupts the stream."""
|
||||
raw = _exchange(_UnfixedHandler, _get_with_body())
|
||||
assert b"401" in raw, raw[:200]
|
||||
poisoned = (
|
||||
b"Bad request syntax" in raw
|
||||
or b"400" in raw
|
||||
or b"501" in raw
|
||||
or b'"status": "ok"' not in raw
|
||||
)
|
||||
assert poisoned, (
|
||||
"expected the unread body to poison the next request on this socket; "
|
||||
f"got {raw[:300]!r}"
|
||||
)
|
||||
@@ -671,7 +671,14 @@ def test_frontend_has_goal_slash_command_and_status_event_handler():
|
||||
assert "goal'" in MESSAGES_JS
|
||||
assert "source.addEventListener('goal'" in MESSAGES_JS
|
||||
assert "source.addEventListener('goal_continue'" in MESSAGES_JS
|
||||
assert "['steer','interrupt','queue','terminal','goal','yolo'].includes(_pc.name)" in MESSAGES_JS
|
||||
# #6962 added 'stop' to this busy-command passthrough list so a busy /stop
|
||||
# cancels immediately. Assert /goal's membership rather than the exact list
|
||||
# literal, so adding another passthrough command does not fail this test.
|
||||
_passthrough = re.search(
|
||||
r"if\(_pc&&\[([^\]]*)\]\.includes\(_pc\.name\)\)", MESSAGES_JS
|
||||
)
|
||||
assert _passthrough, "busy-command passthrough list not found in messages.js"
|
||||
assert "'goal'" in _passthrough.group(1)
|
||||
assert "queueSessionMessage" in MESSAGES_JS
|
||||
|
||||
|
||||
|
||||
@@ -89,7 +89,9 @@ def test_hidden_poll_hits_session_status_and_attaches_as_replay():
|
||||
"""
|
||||
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||
assert start != -1
|
||||
body = MESSAGES_JS[start:start + 2400]
|
||||
# #7299 added a 404/410 terminal-stop branch to the response handler.
|
||||
# The window must cover the original fetch chain + the new branch.
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
assert "api/session/status?session_id=" in body
|
||||
assert "d.active_stream_id" in body
|
||||
# attaches as replay (recovered=true) — turn is already mid-flight
|
||||
@@ -174,7 +176,7 @@ def test_poll_stops_only_when_attach_succeeds():
|
||||
assert start != -1, "_startHiddenActiveStreamPoll signature not found"
|
||||
# Window 2400: the multi-pane follow-up adds an explanatory comment block
|
||||
# before the attach call, pushing it past a narrower slice.
|
||||
body = MESSAGES_JS[start:start + 3200]
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body
|
||||
# Stop the poll only on a true attach (the false branch keeps polling within
|
||||
# the bounded-retry budget rather than stopping).
|
||||
|
||||
@@ -163,9 +163,9 @@ def test_read_importable_agent_session_rows_closes_connection(tmp_path, tracking
|
||||
read_importable_agent_session_rows(db)
|
||||
|
||||
_assert_all_closed(tracking_sqlite, "read_importable_agent_session_rows")
|
||||
# #5455: missing-index self-heal uses one separate short-lived RW connection
|
||||
# in addition to the five read-only listing connections.
|
||||
assert len(tracking_sqlite.instances) == 6
|
||||
# #5455: the listing is strictly read-only, including when indexes are
|
||||
# missing — five calls, five read-only connections, no self-heal writer.
|
||||
assert len(tracking_sqlite.instances) == 5
|
||||
|
||||
|
||||
def test_read_session_lineage_metadata_closes_connection(tmp_path, tracking_sqlite):
|
||||
|
||||
@@ -79,20 +79,19 @@ RAISED_EXCEPTION_SELF_HEAL_MARKER = (
|
||||
"retrying stream after credential refresh')"
|
||||
)
|
||||
|
||||
# Each region ends at the cache write that stores the signature it just
|
||||
# computed. Bounding the region at the write -- rather than at the next
|
||||
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
|
||||
# *before* it is cached: a call moved below its own write lands outside the
|
||||
# region and fails, instead of passing while the write consumes a stale
|
||||
# `_agent_sig`.
|
||||
# Each region ends at the atomic registration/publication call that consumes
|
||||
# the signature it just computed. The shared helper now writes the cache under
|
||||
# the Stop lock; the call boundary must still follow fresh signature computation.
|
||||
# A signature call moved below its publication lands outside the region and
|
||||
# fails, rather than silently reusing a stale `_agent_sig` on a retry.
|
||||
INITIAL_SEND_CACHE_WRITE_MARKER = (
|
||||
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
|
||||
"if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):"
|
||||
)
|
||||
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
|
||||
"_SAC[session_id] = (agent, _agent_sig)"
|
||||
"if not _register_agent_if_current(agent, _agent_sig):"
|
||||
)
|
||||
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
|
||||
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
|
||||
"if not _register_agent_if_current(_heal_agent, _agent_sig):"
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +111,7 @@ def _signature_calls_by_region(
|
||||
|
||||
The regions are the initial send, the returned-error self-heal retry and
|
||||
the raised-exception self-heal retry; each runs from its lifecycle anchor
|
||||
to the cache write that consumes `_agent_sig`. Requiring exactly one call
|
||||
to the atomic publication call that consumes `_agent_sig`. Requiring one call
|
||||
inside those bounds means neither a dropped retry call (masked by its
|
||||
surviving siblings) nor a call recomputed after its own cache write (which
|
||||
would leave the write storing a stale signature) can read as correct.
|
||||
|
||||
@@ -144,7 +144,7 @@ def test_server_bypasses_auth_for_csp_report(monkeypatch):
|
||||
called["path"] = parsed.path
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("server.check_auth", fail_auth)
|
||||
monkeypatch.setattr("server.check_auth_or_close", fail_auth)
|
||||
monkeypatch.setattr("server.clear_request_profile", lambda: None)
|
||||
monkeypatch.setattr("server.get_profile_cookie", lambda _handler: None)
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Regression: the assistant answer is displayed TWICE (#6948 follow-up;
|
||||
upstream symptom report #2051).
|
||||
|
||||
Two independent renderers can put a second copy of the same answer on screen
|
||||
after a turn settles. Both are covered here.
|
||||
|
||||
1. The live-turn node (static/ui.js)
|
||||
------------------------------------
|
||||
`renderMessages()` re-attaches the preserved `#liveAssistantTurn` node after the
|
||||
transcript wipe, and `restoreLiveTurnHtmlForSession()` re-inserts the
|
||||
`INFLIGHT[sid].liveTurnHtml` snapshot on a session switch / reconnect. Each has
|
||||
ONE branch that *appends* — i.e. adds a turn the settled rebuild did not
|
||||
produce. The assistant row is persisted a few ms BEFORE the stream's terminal
|
||||
event clears `S.activeStreamId`, so a dead live node left behind by an INFLIGHT
|
||||
entry that outlived its stream is appended under the settled copy: the same
|
||||
answer twice, self-sustaining across later renders (the node keeps
|
||||
id=liveAssistantTurn and is re-preserved by the #6948 guard), healed only by a
|
||||
reload.
|
||||
|
||||
The contract (`_settledTranscriptOwnsLiveTurn`) is an OWNERSHIP proof, never a
|
||||
rendered-text comparison:
|
||||
|
||||
* the live body comes from the streaming smd parser and the settled body from
|
||||
renderMd + post-processing, so comparing textContent would only ever match
|
||||
single-paragraph plain text — lists, fenced code, tables and `_emphasis_`
|
||||
render differently in the two pipelines;
|
||||
* comparing text would DELETE a genuinely live turn whenever the previous
|
||||
answer reads the same ("Done.", a greeting, a same-prompt resend), because
|
||||
while the current turn is unpersisted the last settled assistant message is
|
||||
the PREVIOUS turn's answer.
|
||||
|
||||
So the append is skipped only when the transcript ENDS with a settled assistant
|
||||
message that the SAME stream produced (persisted anchor identity, or a
|
||||
source-vs-source text match when the turn has no persisted scene), and the
|
||||
preserved node carries nothing the settled rebuild could not have produced (no
|
||||
unpersisted tool card / reasoning row, no second live segment — #3714).
|
||||
|
||||
2. The settled activity scene (static/ui.js)
|
||||
--------------------------------------------
|
||||
`_anchorSceneRowsForRendering(scene,{settled:true})` feeds both settled
|
||||
renderers. A `process_prose` row whose text IS the final answer is suppressed
|
||||
per-row by the transparent-stream renderer but NOT by the compact-worklog row
|
||||
builder, which rebuilds it as a second `.assistant-segment` above the settled
|
||||
answer. The row is now dropped in the shared filter, using a near-equality test
|
||||
without the absolute `shorter>=80` floor that made the existing matcher a no-op
|
||||
for short answers (the reported case: the row is the answer minus its last
|
||||
streamed token, 35 vs 36 chars). LIVE rendering is untouched — while streaming,
|
||||
the inline live segment is hidden and the prose row IS the visible answer.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def js(*names):
|
||||
"""Real source of the named ui.js functions — never a re-implementation."""
|
||||
return "\n".join(extract_function(UI_JS, name) for name in names)
|
||||
|
||||
|
||||
def run_node(script):
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout, out.stdout
|
||||
return out.stdout
|
||||
|
||||
|
||||
# Minimal DOM stand-in: class/attribute selector matching over a node tree.
|
||||
DOM_STUB = """
|
||||
const assert=require('assert');
|
||||
class El{
|
||||
constructor(spec){
|
||||
spec=spec||{};
|
||||
this.classes=new Set(spec.classes||[]);
|
||||
this.attrs=Object.assign({},spec.attrs||{});
|
||||
this.children=spec.children||[];
|
||||
}
|
||||
_matchesToken(tok){
|
||||
tok=String(tok).trim();
|
||||
if(tok.startsWith('.')) return this.classes.has(tok.slice(1));
|
||||
const m=/^\\[([a-zA-Z0-9-]+)="([^"]*)"\\]$/.exec(tok);
|
||||
if(m) return String(this.attrs[m[1]]===undefined?'':this.attrs[m[1]])===m[2];
|
||||
return false;
|
||||
}
|
||||
querySelectorAll(sel){
|
||||
const toks=String(sel).split(',');
|
||||
const out=[];
|
||||
const walk=(node)=>{
|
||||
for(const child of node.children){
|
||||
if(toks.some(t=>child._matchesToken(t))) out.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
const seg=(text)=>new El({attrs:{'data-live-assistant':'1'},classes:['assistant-segment'],
|
||||
children:[new El({classes:['msg-body'],children:[]})]});
|
||||
const turnWith=(...kids)=>new El({classes:['assistant-turn'],children:kids});
|
||||
const toolCard=()=>new El({classes:['tool-card-row']});
|
||||
const emptyWorklogShell=()=>new El({classes:['live-worklog'],attrs:{'data-live-worklog-shell':'1'}});
|
||||
var S={messages:[],activeStreamId:null,session:{session_id:'s1'}};
|
||||
var INFLIGHT={};
|
||||
function setState(messages,inflight,activeStreamId){
|
||||
S.messages=messages;
|
||||
S.activeStreamId=activeStreamId===undefined?null:activeStreamId;
|
||||
for(const k of Object.keys(INFLIGHT)) delete INFLIGHT[k];
|
||||
if(inflight) INFLIGHT['s1']=inflight;
|
||||
}
|
||||
const user=(content)=>({role:'user',content});
|
||||
const settledAnswer=(content,streamId)=>{
|
||||
const m={role:'assistant',content};
|
||||
if(streamId) m._anchor_stream_id=streamId;
|
||||
return m;
|
||||
};
|
||||
const sceneIdentityAnswer=(content,streamId)=>({
|
||||
role:'assistant',content,
|
||||
_anchor_activity_scene:{identity:{stream_id:streamId},final_answer:content},
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_dead_live_turn_is_dropped_only_when_the_stream_is_provably_settled():
|
||||
"""The #2051 duplicate is suppressed; a live turn is never deleted."""
|
||||
script = DOM_STUB + js(
|
||||
"msgContent",
|
||||
"_settledAssistantStreamId",
|
||||
"_messageHasLiveAssistantProjection",
|
||||
"_liveTurnCarriesUnsettledContent",
|
||||
"_settledTranscriptOwnsLiveTurn",
|
||||
) + """
|
||||
const ANSWER='Hey! What can I help you with today?';
|
||||
|
||||
// ── the bug: stream settled, INFLIGHT not yet cleaned → dead leftover ──
|
||||
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER),emptyWorklogShell())),true,
|
||||
'dead live node whose stream the settled transcript owns must be dropped (#2051)');
|
||||
|
||||
// Identity may live only on the persisted scene.
|
||||
setState([user('Hi'),sceneIdentityAnswer(ANSWER,'st1')],{streamId:'st1'},null);
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
|
||||
'scene identity.stream_id is accepted as ownership proof');
|
||||
|
||||
// Shape independence: the decision must not depend on rendered text at all, so
|
||||
// a multi-block answer (fenced code / list / table), which the smd parser and
|
||||
// renderMd render to DIFFERENT textContent, is handled like plain prose.
|
||||
setState([user('Hi'),settledAnswer('Here you go:\\n\\n```python\\nprint(1)\\n```','st1')],
|
||||
{streamId:'st1'},'st1');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Here you go:print(1)'))),true,
|
||||
'a code-block answer must be de-duplicated too (renderer-independent)');
|
||||
|
||||
// ── false positives that a text comparison would cause ──
|
||||
// The current turn is unpersisted: the transcript ends with the USER turn while
|
||||
// the live turn streams text identical to the PREVIOUS answer.
|
||||
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{streamId:'st2'},'st2');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'a genuinely live turn must never be dropped because an earlier answer matches');
|
||||
// markInflight() shape at send time (no snapshot yet, stream just started).
|
||||
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{sid:'s1',streamId:'st2',ts:1},'st2');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Do'))),false,
|
||||
'partial live text under a fresh stream must never be dropped');
|
||||
// A settled tail from a DIFFERENT stream never owns this live turn.
|
||||
setState([user('Hi'),settledAnswer('Done.','st1')],{streamId:'st2'},'st2');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'a tail from another stream must not authorize dropping the live turn');
|
||||
// Reconnect/terminal projection: S.messages still carries a live marker.
|
||||
setState([user('Hi'),Object.assign(settledAnswer('Done.','st1'),{_live:true})],
|
||||
{streamId:'st1'},null);
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'a live projection in S.messages means the turn has not settled');
|
||||
// Ownership unknown (no INFLIGHT stream id, no active stream) → fail closed.
|
||||
setState([user('Hi'),settledAnswer('Done.','st1')],{},null);
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'unknown live owner must fail closed');
|
||||
setState([],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'an empty transcript owns nothing');
|
||||
|
||||
// ── #3714: live-only structure is never discarded ──
|
||||
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(toolCard(),seg(ANSWER))),false,
|
||||
'a preserved turn carrying an unpersisted tool card must be kept');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('first'),seg(ANSWER))),false,
|
||||
'a multi-segment live turn must not be dropped on a tail-segment match');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(new El({classes:['wl-reason']}),seg(ANSWER))),false,
|
||||
'a preserved turn carrying an unpersisted reasoning row must be kept');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',{}),false,
|
||||
'an unknown node shape must fail closed');
|
||||
|
||||
// ── no persisted scene identity: SOURCE text vs SOURCE text ──
|
||||
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1',lastAssistantText:ANSWER},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
|
||||
'streamed markdown identical to the persisted answer proves ownership');
|
||||
setState([user('Hi'),settledAnswer(ANSWER,null)],
|
||||
{streamId:'st1',lastAssistantText:'Hey! What can I help'},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Hey! What can I help'))),false,
|
||||
'a still-growing stream must not be dropped against a longer settled answer');
|
||||
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),false,
|
||||
'no identity and no streamed source text → fail closed');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_append_decision_renders_the_answer_exactly_once():
|
||||
"""Observable outcome of the two append branches, driven by the real
|
||||
predicate: one copy for a settled leftover, and the live turn is still
|
||||
attached in every case where the turn may still be running."""
|
||||
script = DOM_STUB + js(
|
||||
"msgContent",
|
||||
"_settledAssistantStreamId",
|
||||
"_messageHasLiveAssistantProjection",
|
||||
"_liveTurnCarriesUnsettledContent",
|
||||
"_settledTranscriptOwnsLiveTurn",
|
||||
) + """
|
||||
// Mirrors the two call sites: the branch appends unless the settled transcript
|
||||
// provably owns the node (static/ui.js renderMessages re-attach and
|
||||
// restoreLiveTurnHtmlForSession).
|
||||
function visibleAnswers(messages,inflight,activeStreamId,turn){
|
||||
setState(messages,inflight,activeStreamId);
|
||||
const settledCopies=messages.filter(m=>m.role==='assistant'&&!m._live).length;
|
||||
const appended=!_settledTranscriptOwnsLiveTurn('s1',turn)?1:0;
|
||||
return {settledCopies,appended,total:settledCopies+appended};
|
||||
}
|
||||
const ANSWER='Hey! What can I help you with today?';
|
||||
// #2051: exactly one copy of the answer.
|
||||
assert.deepStrictEqual(
|
||||
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
|
||||
turnWith(seg(ANSWER),emptyWorklogShell())),
|
||||
{settledCopies:1,appended:0,total:1},
|
||||
'the settled answer must render exactly once');
|
||||
// A live turn is still attached while the turn is running (#3877).
|
||||
assert.deepStrictEqual(
|
||||
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1'),user('Hi again')],
|
||||
{streamId:'st2'},'st2',turnWith(seg(ANSWER))),
|
||||
{settledCopies:1,appended:1,total:2},
|
||||
'a running turn must stay on screen even when it repeats an earlier answer');
|
||||
// A leftover carrying unpersisted structure is kept whole (#3714).
|
||||
assert.deepStrictEqual(
|
||||
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
|
||||
turnWith(toolCard(),seg(ANSWER))).appended,1,
|
||||
'never discard live-only blocks the rebuild did not produce');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_settled_scene_drops_the_prose_row_that_is_the_final_answer():
|
||||
script = DOM_STUB + js(
|
||||
"_anchorSceneIsSettledSuccessfulCompression",
|
||||
"_anchorSceneProseMatchesFinalAnswer",
|
||||
"_anchorSceneProseDuplicatesFinalAnswer",
|
||||
"_anchorSceneRowsForRendering",
|
||||
) + """
|
||||
function _anchorSceneToolRowLogicalKey(row){ return (row&&row.row_id)||''; }
|
||||
function _anchorSceneMergeToolRows(_prev,row){ return row; }
|
||||
const FINAL='Hey! What can I help you with today?';
|
||||
const scene={
|
||||
final_answer:FINAL,
|
||||
activity_rows:[
|
||||
{role:'thinking',row_id:'t1',text:'thinking out loud'},
|
||||
{role:'prose',row_id:'p1',text:'Let me check the config first.'},
|
||||
// the reported shape: the answer minus its last streamed token (35 vs 36)
|
||||
{role:'prose',row_id:'p2',text:'Hey! What can I help you with today'},
|
||||
{role:'terminal',row_id:'d1',source_event_type:'done'},
|
||||
],
|
||||
};
|
||||
const texts=(opts)=>_anchorSceneRowsForRendering(scene,opts).map(r=>r.text);
|
||||
assert.deepStrictEqual(texts({settled:true}),['thinking out loud','Let me check the config first.'],
|
||||
'the settled scene must not rebuild the final answer as a second segment (#2051)');
|
||||
assert.deepStrictEqual(texts({settled:false}),
|
||||
['thinking out loud','Let me check the config first.','Hey! What can I help you with today'],
|
||||
'LIVE rendering must keep the prose row — it IS the visible answer while streaming');
|
||||
// Exact repeats and multi-block answers are dropped too; source text is compared,
|
||||
// so the markdown pipeline never matters here.
|
||||
const code={final_answer:'Here you go:\\n\\n```python\\nprint(1)\\n```',activity_rows:[
|
||||
{role:'prose',row_id:'p1',text:'Here you go:\\n\\n```python\\nprint(1)\\n```'},
|
||||
{role:'prose',row_id:'p2',text:'Working on it.'}]};
|
||||
assert.deepStrictEqual(_anchorSceneRowsForRendering(code,{settled:true}).map(r=>r.text),
|
||||
['Working on it.'],'an exact fenced-code duplicate must be dropped, intermediates kept');
|
||||
// Codex #4568: a SHORT distinct intermediate that merely prefixes a LONG answer
|
||||
// must survive — the >=0.9 length ratio, not the removed absolute floor, is what
|
||||
// protects it.
|
||||
const long_={final_answer:'Hey! What can I help you with today? I can read files, run commands and edit code.',
|
||||
activity_rows:[{role:'prose',row_id:'p1',text:'Hey! What can'}]};
|
||||
assert.deepStrictEqual(_anchorSceneRowsForRendering(long_,{settled:true}).map(r=>r.text),
|
||||
['Hey! What can'],'a distinct short intermediate must not be swallowed by a long answer');
|
||||
// The pinned per-row matcher is untouched: its absolute floor still rejects the
|
||||
// short near-miss that the new helper accepts.
|
||||
assert.strictEqual(_anchorSceneProseMatchesFinalAnswer('Hey! What can I help you with today',FINAL),false,
|
||||
'_anchorSceneProseMatchesFinalAnswer must keep its >=80 char floor');
|
||||
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can I help you with today',FINAL),true,
|
||||
'the new helper must catch the short near-miss');
|
||||
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can',FINAL),false,
|
||||
'the new helper must keep the >=0.9 ratio guard');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
def test_both_append_branches_are_gated_by_the_ownership_check():
|
||||
"""Wiring: the predicate only helps if it guards the two branches that ADD a
|
||||
turn (the replace branches cannot duplicate and must stay unguarded)."""
|
||||
restore = UI_JS[UI_JS.index("function restoreLiveTurnHtmlForSession"):]
|
||||
restore = restore[:restore.index("\nfunction markInflight")]
|
||||
assert "if(existing) existing.replaceWith(restored);" in restore
|
||||
assert "_settledTranscriptOwnsLiveTurn(sid, restored)" in restore, (
|
||||
"the restore append branch must be gated by the ownership check"
|
||||
)
|
||||
assert "inflight.liveTurnHtml=null;" in restore, (
|
||||
"a refused snapshot must be released, or it is re-refused on every restore"
|
||||
)
|
||||
reattach = UI_JS[UI_JS.index(" const _rebuilt=document.getElementById('liveAssistantTurn');"):]
|
||||
reattach = reattach[:reattach.index(" // Only force-scroll when not actively streaming")]
|
||||
assert "_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn)" in reattach, (
|
||||
"the renderMessages append branch must be gated by the ownership check"
|
||||
)
|
||||
assert reattach.count("_settledTranscriptOwnsLiveTurn(") == 1, (
|
||||
"the segment-swap and whole-turn replace branches must stay unguarded — "
|
||||
"they replace a node instead of adding one and cannot duplicate"
|
||||
)
|
||||
|
||||
|
||||
def test_segment_block_insertion_moves_nodes_instead_of_reparsing_html():
|
||||
"""Wiring: every message-block insertion in renderMessages() goes through the
|
||||
helper, and the helper MOVES parsed nodes instead of re-parsing a string.
|
||||
|
||||
Appending a DocumentFragment moves its children and empties it, so a DOM-API
|
||||
wrapper that executes the call twice appends nothing the second time; an
|
||||
insertAdjacentHTML wrapper that does the same parses and appends a second copy.
|
||||
That difference is the whole fix, so it is pinned here rather than left to the
|
||||
three call sites.
|
||||
"""
|
||||
helper = UI_JS[UI_JS.index("function _insertSegmentBlock("):]
|
||||
helper = helper[:helper.index("\nfunction renderMessages")]
|
||||
assert "createElement('template')" in helper, (
|
||||
"the block must be parsed into a <template>, not inserted as a string"
|
||||
)
|
||||
assert "seg.appendChild(tpl.content)" in helper, (
|
||||
"the template's content must be MOVED into the segment — that is what makes "
|
||||
"a repeated call a no-op"
|
||||
)
|
||||
assert helper.count("insertAdjacentHTML") == 1, (
|
||||
"insertAdjacentHTML may survive only as the fallback for an environment "
|
||||
"without <template>"
|
||||
)
|
||||
render = UI_JS[UI_JS.index("function renderMessages(options){"):]
|
||||
assert render.count("_insertSegmentBlock(") == 3, (
|
||||
"all three assistant-segment block insertions must go through the helper"
|
||||
)
|
||||
assert "seg.insertAdjacentHTML('beforeend', `${filesHtml}" not in render, (
|
||||
"an assistant segment's block must not be built from an HTML string again"
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Regression: an interrupted turn's notice — and any turn-final answer — is
|
||||
displayed twice (#6948 / #2051).
|
||||
|
||||
A ``hermes chat`` run killed mid tool-call persists a single assistant message
|
||||
``Operation interrupted.``; the WebUI renders that notice twice. The same
|
||||
doubling was reported for a long tool run's final answer and for an image reply
|
||||
carrying a ``MEDIA:`` line. The stored data is clean in every case — one message
|
||||
in ``state.db``, one in the session sidecar, one in ``/api/session`` — so both
|
||||
copies are produced client-side.
|
||||
|
||||
``renderMessages()`` folds intermediate assistant segments into the collapsed
|
||||
Worklog card (#3401): the inline segment is classed
|
||||
``assistant-segment-worklog-source`` (``display:none``) and its prose is
|
||||
re-rendered as a ``.wl-reason`` row inside the Worklog. Two copies, one hidden —
|
||||
correct, as long as the fold decision is correct.
|
||||
|
||||
``_assistantMessageBelongsInWorklog()`` folded on ``m._live`` before it could
|
||||
reach the rule that protects the answer (``hasVisibleText && isTurnFinalAssistant
|
||||
-> false``). Its caller only consults the predicate once the turn has settled (it
|
||||
gates on ``!S.busy``), so an ``_live`` marker seen there is a leftover of the
|
||||
live-snapshot projection rather than an ongoing stream. Folding on it hides the
|
||||
answer inline and echoes it into the Worklog: the turn then holds two copies and
|
||||
no visible content, which the #3875 blank-turn fail-safe reveals — by expanding
|
||||
the Worklog, or by un-hiding the source segments — so the user sees the notice or
|
||||
answer twice.
|
||||
|
||||
Three contracts are pinned here:
|
||||
|
||||
1. ``_assistantMessageBelongsInWorklog``: a live marker may no longer outrank the
|
||||
turn-final visible answer. Every other ``_live`` message folds as before, and
|
||||
the change can only keep a segment visible, never hide or drop one.
|
||||
2. ``renderMessages``: the Worklog may echo an anchor's prose as a ``.wl-reason``
|
||||
row only when that anchor was folded into it —
|
||||
``assistant-segment-worklog-source`` is that proof, and its ``display:none``
|
||||
is the only reason the echo is not a second visible copy. This also covers
|
||||
anchors that escape the fold for other reasons, such as ``_error`` messages,
|
||||
which are never folded.
|
||||
3. The #3875 fail-safe must materialize a deferred settled Worklog (#5839) before
|
||||
judging it empty; otherwise it falls through to the last-resort un-hide,
|
||||
strips the fold off every source segment, and the deferred rows then render
|
||||
the same prose beside them.
|
||||
|
||||
Each contract is a structural/ownership check rather than a rendered-text
|
||||
comparison, and each fails towards keeping content: the first only un-hides, the
|
||||
second only suppresses an echo whose original is provably visible, the third only
|
||||
builds rows earlier.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def js(*names):
|
||||
"""Real source of the named ui.js functions — never a re-implementation."""
|
||||
return "\n".join(extract_function(UI_JS, name) for name in names)
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = src.find(marker)
|
||||
assert start != -1, f"{name} not found"
|
||||
brace = src.find("{", start)
|
||||
assert brace != -1, f"{name} body not found"
|
||||
depth = 0
|
||||
for idx in range(brace, len(src)):
|
||||
ch = src[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace + 1 : idx]
|
||||
raise AssertionError(f"{name} body not closed")
|
||||
|
||||
|
||||
def run_node(script):
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout, out.stdout
|
||||
return out.stdout
|
||||
|
||||
|
||||
HARNESS = """
|
||||
const assert=require('assert');
|
||||
var window={};
|
||||
function _messageHasReasoningPayload(m){ return !!(m&&(m.reasoning||m.reasoning_content)); }
|
||||
const assistant=(content,extra)=>Object.assign({role:'assistant',content},extra||{});
|
||||
"""
|
||||
|
||||
|
||||
def test_live_marker_guard_precedes_the_live_fold_rule():
|
||||
"""Contract 1, structurally: the guard sits before the live fold rule.
|
||||
|
||||
Written as an added guard rather than an edit to `if(m._live) return true;`
|
||||
so the live rule keeps the exact shape and ordering pinned by
|
||||
tests/test_live_to_final_anchor_visible_order.py.
|
||||
"""
|
||||
belongs = _function_body(UI_JS, "_assistantMessageBelongsInWorklog")
|
||||
guard = "if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;"
|
||||
assert guard in belongs, (
|
||||
"a settled-render live remnant must not fold the turn's final visible answer"
|
||||
)
|
||||
assert belongs.index(guard) < belongs.index("if(m._live) return true;"), (
|
||||
"the guard must be evaluated before the unconditional live fold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_stale_live_marker_never_folds_the_turn_final_answer():
|
||||
"""The fold must not swallow the answer on a settled-render live remnant."""
|
||||
script = HARNESS + js(
|
||||
"msgContent",
|
||||
"_isAssistantEmptyPlaceholderContent",
|
||||
"_assistantMessageBelongsInWorklog",
|
||||
) + """
|
||||
const FINAL={isTurnFinalAssistant:true};
|
||||
const MID={isTurnFinalAssistant:false};
|
||||
const owners=new Set([0]);
|
||||
|
||||
// The bug: the settled turn-final answer folds on a stale live marker.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.',{_live:true}),0,owners,undefined,FINAL),
|
||||
false,
|
||||
'a settled turn-final notice must never be folded into the Worklog (#2051)');
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(
|
||||
assistant('Here is the image you asked for.\\n\\nMEDIA:/tmp/example.png',{_live:true}),0,owners,undefined,FINAL),
|
||||
false,
|
||||
'a MEDIA-bearing turn-final answer must never be folded either');
|
||||
// Renderer-independent: the decision must not depend on what the text looks like.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(
|
||||
assistant('Here you go:\\n\\n```python\\nprint(1)\\n```',{_live:true}),0,owners,undefined,FINAL),
|
||||
false,
|
||||
'a fenced-code turn-final answer must never be folded');
|
||||
|
||||
// Everything else about the fold is unchanged.
|
||||
// A live intermediate segment still folds — that is the Worklog's content.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Reading the files now.',{_live:true}),0,owners,undefined,MID),
|
||||
true,
|
||||
'a live intermediate segment must still fold into the Worklog');
|
||||
// A live turn-final message with no visible text still folds (an empty tool-call
|
||||
// anchor is Worklog material, not an answer).
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('',{_live:true}),0,owners,undefined,FINAL),
|
||||
true,
|
||||
'an empty live anchor must still fold');
|
||||
// `(empty)` placeholder + reasoning payload is not visible text either.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(
|
||||
assistant('(empty)',{_live:true,reasoning:'thought'}),0,owners,undefined,FINAL),
|
||||
true,
|
||||
'an (empty) placeholder anchor must still fold');
|
||||
// Settled (no marker at all) is untouched in both directions.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.'),0,owners,undefined,FINAL),
|
||||
false, 'settled turn-final answer: unchanged');
|
||||
// (A settled message with visible text and no live/burst marker never folds —
|
||||
// `if(hasVisibleText) return false;` precedes the tool-metadata rule. Pinned so a
|
||||
// future change to the live-marker line cannot quietly alter this ordering.)
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Reading the files now.'),0,owners,undefined,MID),
|
||||
false, 'settled visible intermediate without a live/burst marker: unchanged');
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('',{}),0,owners,undefined,MID),
|
||||
true, 'settled empty tool-call anchor still folds: unchanged');
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Done.'),0,new Set(),undefined,MID),
|
||||
false, 'settled intermediate with no tool metadata: unchanged');
|
||||
// An error message is still never folded.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('**Error:** boom',{_error:true,_live:true}),0,owners,undefined,MID),
|
||||
false, 'an error message is never folded');
|
||||
// Live burst/segment markers on a non-final message still fold.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('mid',{_activityBurstId:2}),0,new Set(),undefined,MID),
|
||||
true, 'activity-burst anchors still fold');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
def test_worklog_reason_echo_requires_a_folded_anchor():
|
||||
"""Contract 2: only a folded anchor may be echoed as a .wl-reason row."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
assert "includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource," in body, (
|
||||
"the settled Worklog step may only re-render an anchor's prose when that "
|
||||
"anchor was folded into it (assistant-segment-worklog-source); without "
|
||||
"that proof the row is a second visible copy of a visible segment"
|
||||
)
|
||||
# The ownership fact must be computed for every entry, not only for the first
|
||||
# entry of a turn (where the group is constructed) — otherwise the append path
|
||||
# would read a stale/undefined value for later anchors in the same turn.
|
||||
hoisted = body.find(
|
||||
"const anchorIsWorklogSource=anchorRow.classList"
|
||||
"&&anchorRow.classList.contains('assistant-segment-worklog-source');"
|
||||
)
|
||||
state_lookup = body.find("let state=activityByTurn.get(anchorTurn);")
|
||||
assert hoisted != -1 and state_lookup != -1, "settled worklog append path not found"
|
||||
assert hoisted < state_lookup, (
|
||||
"anchorIsWorklogSource must be computed per activity entry (before the "
|
||||
"per-turn `state` lookup), not only inside the group-construction branch"
|
||||
)
|
||||
|
||||
|
||||
def test_blank_turn_failsafe_materializes_deferred_rows_before_judging_empty():
|
||||
"""Contract 3: a deferred (#5839) Worklog is not empty."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
failsafe = body[body.find("Fail-safe invariant (#3875)"):]
|
||||
assert failsafe, "the #3875 fail-safe is missing"
|
||||
materialize = failsafe.find("data-worklog-rows-deferred")
|
||||
empty_check = failsafe.find("continue; // empty group can't help")
|
||||
assert materialize != -1, (
|
||||
"the fail-safe must materialize a deferred settled Worklog before judging "
|
||||
"it empty — otherwise it un-hides every folded segment and the deferred "
|
||||
"rows later render the same prose beside them"
|
||||
)
|
||||
assert materialize < empty_check, (
|
||||
"deferred rows must be materialized before the empty-group check"
|
||||
)
|
||||
# The last-resort un-hide must still exist: content is never lost.
|
||||
assert "seg.classList.remove('assistant-segment-worklog-source');" in failsafe, (
|
||||
"the last-resort un-hide must remain — a duplicate is preferable to a "
|
||||
"blank turn"
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Regression: the composer model picker lists one configured model twice (#2051).
|
||||
|
||||
The picker showed two rows for a single configured model, ``acme/example-model``
|
||||
and ``custom/acme/example-model``, while ``GET /api/models`` returned a ``Custom``
|
||||
group holding exactly one model and ``#modelSelect`` ended up with exactly one
|
||||
matching ``<option>``. Both rows are produced client-side.
|
||||
|
||||
``configured_model_badges`` carries every routing spelling of a configured model.
|
||||
For one custom-provider model that is three keys::
|
||||
|
||||
acme/example-model (bare/canonical)
|
||||
custom/acme/example-model (provider-qualified)
|
||||
@custom:acme/example-model (routable)
|
||||
|
||||
``renderModelDropdown()`` walks that map and pushes a synthetic ``_modelData``
|
||||
row for every key that ``_isEquivalentConfiguredModelEntry()`` does not recognise
|
||||
as an alias of a row the catalog already produced. That predicate recognised two
|
||||
of the three spellings; it missed ``<provider>/<model>`` because
|
||||
``_normalizeConfiguredModelKey()`` strips only one leading slash segment —
|
||||
deliberately, since #3360 requires ``vendor_a/x`` and ``vendor_b/y/x`` to stay
|
||||
distinct. So::
|
||||
|
||||
acme/example-model -> example.model
|
||||
custom/acme/example-model -> acme/example.model (different key)
|
||||
|
||||
No match, so the alias was pushed as a second row: one model, two picker entries.
|
||||
``_deduplicateModelPickerOptions()`` cannot catch this — it dedupes ``<option>``
|
||||
elements inside ``<select>``, and this row never becomes an ``<option>``.
|
||||
|
||||
The contract pinned here: ``<provider>/<model>`` is recognised as an alias of
|
||||
``<model>`` under the same proof the ``@<provider>:<model>`` rule already uses.
|
||||
The badge declares a provider, the key begins with that provider's prefix, and an
|
||||
existing picker row from that same provider normalises equal to the remainder.
|
||||
Two genuinely different models never satisfy the last clause, so the change can
|
||||
only drop a duplicate — never hide a distinct model.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def js(*names):
|
||||
"""Real source of the named ui.js functions — never a re-implementation."""
|
||||
return "\n".join(extract_function(UI_JS, name) for name in names)
|
||||
|
||||
|
||||
def run_node(script):
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout, out.stdout
|
||||
return out.stdout
|
||||
|
||||
|
||||
HARNESS = """
|
||||
const assert=require('assert');
|
||||
var window={};
|
||||
"""
|
||||
|
||||
# One Custom group holding one model, with three badge spellings of it.
|
||||
CATALOG_ROW = "{value:'acme/example-model',providerId:'custom'}"
|
||||
BADGE = "{role:'primary',label:'Primary',provider:'custom'}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_provider_qualified_badge_alias_is_not_a_second_picker_row():
|
||||
"""All three spellings of one configured model collapse to one row (#2051)."""
|
||||
script = HARNESS + js(
|
||||
"_normalizeConfiguredModelKey",
|
||||
"_isEquivalentConfiguredModelEntry",
|
||||
) + f"""
|
||||
const entries=[{CATALOG_ROW}];
|
||||
const badge={BADGE};
|
||||
|
||||
// All three routing spellings name the same model the catalog already rendered.
|
||||
for(const alias of ['acme/example-model',
|
||||
'custom/acme/example-model',
|
||||
'@custom:acme/example-model']){{
|
||||
assert.strictEqual(_isEquivalentConfiguredModelEntry(alias,badge,entries), true,
|
||||
'badge alias '+alias+' must not become a second picker row (#2051)');
|
||||
}}
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_a_genuinely_different_model_is_never_suppressed():
|
||||
"""The new rule must not hide a model the catalog does not have."""
|
||||
script = HARNESS + js(
|
||||
"_normalizeConfiguredModelKey",
|
||||
"_isEquivalentConfiguredModelEntry",
|
||||
) + f"""
|
||||
const entries=[{CATALOG_ROW}];
|
||||
const badge={BADGE};
|
||||
|
||||
// Same provider prefix, different model -> must still render its own row.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry('custom/acme/other-model',badge,entries), false,
|
||||
'a different model under the same provider must keep its own row');
|
||||
|
||||
// Right model name, but no catalog row owned by that provider -> not proven
|
||||
// equivalent, so keep it.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry(
|
||||
'custom/acme/example-model',
|
||||
{{role:'primary',label:'Primary',provider:'otherprovider'}},
|
||||
[{{value:'acme/example-model',providerId:'custom'}}]), false,
|
||||
'a same-named model owned by a different provider must keep its own row');
|
||||
|
||||
// A badge with no provider cannot use the prefix proof at all.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry('custom/acme/example-model',
|
||||
{{role:'primary',label:'Primary'}},entries), false,
|
||||
'a provider-less badge must not be collapsed by the prefix rule');
|
||||
|
||||
// The prefix must be a real path segment, not a substring of the first segment.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry('customer/acme/example-model',badge,entries), false,
|
||||
'only a literal `<provider>/` prefix counts');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
def test_prefix_rule_mirrors_the_at_provider_rule_structurally():
|
||||
"""The `<provider>/` proof must require the same three clauses as `@<provider>:`.
|
||||
|
||||
Structural, not behavioural: it pins that the new branch is gated on the
|
||||
badge-declared provider, on a literal prefix match, and on an existing entry
|
||||
of that same provider — so it cannot grow into a loose label comparison.
|
||||
"""
|
||||
src = extract_function(UI_JS, "_isEquivalentConfiguredModelEntry")
|
||||
assert "const slashPrefix=provider?`${provider}/`:'';" in src, (
|
||||
"the provider-qualified alias rule must derive its prefix from the "
|
||||
"badge-declared provider"
|
||||
)
|
||||
assert "rawId.toLowerCase().startsWith(slashPrefix)" in src, (
|
||||
"the rule must require a literal `<provider>/` prefix on the badge key"
|
||||
)
|
||||
assert "String(entry.providerId||'').toLowerCase()===provider" in src, (
|
||||
"the rule must require an existing picker row owned by the same provider"
|
||||
)
|
||||
# It must run before the `@provider:` early return, which would otherwise
|
||||
# short-circuit every non-`@` alias to false.
|
||||
assert src.index("slashPrefix") < src.index(
|
||||
"if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;"
|
||||
), "the `<provider>/` rule must be reached before the `@<provider>:` early return"
|
||||
@@ -183,6 +183,7 @@ def test_optimistic_merge_preserves_server_running_state_when_local_cache_is_sta
|
||||
optimistic_body = _function_body(SESSIONS_SRC, "function _isOptimisticFirstTurnSessionRow(")
|
||||
keep_body = _function_body(SESSIONS_SRC, "function _shouldKeepLocalOnlyOptimisticSessionRow(")
|
||||
drop_body = _function_body(SESSIONS_SRC, "function _dropStaleOptimisticSessionRow(")
|
||||
owner_body = _function_body(SESSIONS_SRC, "function _hasOwnedOpenLiveStream(")
|
||||
merge_body = _function_body(SESSIONS_SRC, "function _mergeOptimisticFirstTurnSessions(")
|
||||
|
||||
script = f"""
|
||||
@@ -206,6 +207,7 @@ function _isServerIdleSessionRow(s) {{{helper_body}}}
|
||||
function _isSessionLocallyStreaming(s) {{{local_body}}}
|
||||
function _isOptimisticFirstTurnSessionRow(s) {{{optimistic_body}}}
|
||||
function _shouldKeepLocalOnlyOptimisticSessionRow(local) {{{keep_body}}}
|
||||
function _hasOwnedOpenLiveStream(sid) {{{owner_body}}}
|
||||
function _dropStaleOptimisticSessionRow(sid) {{{drop_body}}}
|
||||
function _mergeOptimisticFirstTurnSessions(fetchedSessions) {{{merge_body}}}
|
||||
const merged = _mergeOptimisticFirstTurnSessions([{{
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Regression coverage for capped CLI/agent session sidebar scans (#2628)."""
|
||||
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
import api.agent_sessions as agent_sessions
|
||||
|
||||
_REAL_SQLITE_CONNECT = sqlite3.connect
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _make_state_db(path, *, sessions=80, messages_per_session=3, create_messages_index=True, source="cli", session_source="cli"):
|
||||
@@ -169,6 +168,24 @@ def _make_connect_with_progress_budget(*, budget_ops, interval=1):
|
||||
return _connect
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _capture_listing_sql():
|
||||
"""Record the SQL statements the listing runs (trace callback, read-only)."""
|
||||
statements = []
|
||||
|
||||
def _tracing_connect(*args, **kwargs):
|
||||
conn = _REAL_SQLITE_CONNECT(*args, **kwargs)
|
||||
conn.set_trace_callback(statements.append)
|
||||
return conn
|
||||
|
||||
original_connect = agent_sessions.sqlite3.connect
|
||||
agent_sessions.sqlite3.connect = _tracing_connect
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
agent_sessions.sqlite3.connect = original_connect
|
||||
|
||||
|
||||
def _make_connect_with_progress_counter(*, interval=1):
|
||||
steps = {"count": 0}
|
||||
|
||||
@@ -198,24 +215,59 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path):
|
||||
db = tmp_path / "state.db"
|
||||
_make_state_db(db, sessions=120, messages_per_session=5)
|
||||
|
||||
with _capture_listing_sql() as statements:
|
||||
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
|
||||
|
||||
assert len(rows) == 20
|
||||
assert [row["id"] for row in rows][:3] == ["cli_perf_0119", "cli_perf_0118", "cli_perf_0117"]
|
||||
assert {row["actual_message_count"] for row in rows} == {5}
|
||||
|
||||
src = (REPO_ROOT / "api" / "agent_sessions.py").read_text()
|
||||
assert "WITH candidates AS" in src
|
||||
assert "JOIN candidates c ON c.id = s.id" in src
|
||||
assert "latest_messages AS" in src
|
||||
assert "LEFT JOIN latest_messages lm ON lm.session_id = s.id" in src
|
||||
assert 'included == ("cron",)' in src
|
||||
assert "not messages_index_present" in src
|
||||
assert "PRAGMA index_list(messages)" in src
|
||||
assert "CREATE INDEX IF NOT EXISTS idx_messages_session" in src
|
||||
assert "_CRON_PREAGGREGATE_CANDIDATE_ORDER_MIN_MESSAGES" not in src
|
||||
assert "MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src
|
||||
assert "candidate_limit = max(result_limit * 8, result_limit)" in src
|
||||
candidates_sql = next(
|
||||
statement
|
||||
for statement in statements
|
||||
if "LIMIT" in statement and "FROM sessions s" in statement
|
||||
)
|
||||
# The cap is applied inside the candidate subquery, not in Python after an
|
||||
# unbounded aggregate: the oversampled window (limit * 8 = 160 here) is
|
||||
# part of the statement SQLite actually runs, and it bounds the candidate
|
||||
# subquery that the outer projection joins.
|
||||
assert candidates_sql.count("LIMIT") == 1, candidates_sql
|
||||
window_end = candidates_sql.index("LIMIT")
|
||||
window = candidates_sql[:window_end]
|
||||
assert "JOIN candidates" not in window, candidates_sql
|
||||
cap = int(candidates_sql[window_end:].split(None, 2)[1])
|
||||
assert cap == 160, candidates_sql
|
||||
|
||||
|
||||
def test_importable_agent_rows_candidate_window_overfilters_then_fills(tmp_path):
|
||||
"""Rows dropped after projection must not shrink the returned page.
|
||||
|
||||
The SQL candidate window is oversampled (limit * 8) precisely so sessions
|
||||
filtered client-side of the SQL (invisible CLI rows here) only consume
|
||||
candidates, not result slots: a page that would be short if the cap were
|
||||
the plain ``limit`` still fills to ``limit`` visible rows.
|
||||
"""
|
||||
db = tmp_path / "state.db"
|
||||
_make_state_db(db, sessions=120, messages_per_session=5)
|
||||
|
||||
# 30 newest sessions are CLI rows that the projection hides (ended, default
|
||||
# title, no user turn visible): they must fall out of the window without
|
||||
# starving the visible page below them.
|
||||
conn = sqlite3.connect(str(db))
|
||||
for i in range(90, 120):
|
||||
conn.execute(
|
||||
"UPDATE sessions SET title = 'cli session', ended_at = 1.0, end_reason = 'timeout' WHERE id = ?",
|
||||
(f"cli_perf_{i:04d}",),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
|
||||
|
||||
assert len(rows) == 20
|
||||
assert rows[0]["id"] == "cli_perf_0089"
|
||||
assert rows[-1]["id"] == "cli_perf_0070"
|
||||
assert {row["actual_message_count"] for row in rows} == {5}
|
||||
|
||||
|
||||
def test_importable_agent_rows_candidate_ordering_stays_under_progress_budget(tmp_path, monkeypatch):
|
||||
|
||||
@@ -71,6 +71,35 @@ def test_cjk_start_english_title_is_rejected():
|
||||
assert _title_language_mismatch("如何修复这个错误问题", "Fixing the Bug") is True
|
||||
|
||||
|
||||
def test_cjk_start_mixed_cjk_latin_title_is_allowed():
|
||||
"""CJK conversations frequently embed English product/technical terms in
|
||||
titles (e.g. 'WeChat Pay 回调失败排查'). As long as the title also
|
||||
contains CJK characters, Latin borrowed terms should not trigger rejection.
|
||||
Regression test for #7693."""
|
||||
from api.streaming import _title_language_mismatch
|
||||
|
||||
# Pure CJK user, CJK title with English product name
|
||||
assert _title_language_mismatch(
|
||||
"微信支付回调一直失败怎么办", "WeChat Pay 回调失败排查"
|
||||
) is False
|
||||
# CJK user, CJK title with English tech term
|
||||
assert _title_language_mismatch(
|
||||
"如何修复这个错误问题", "Python 代码修复"
|
||||
) is False
|
||||
# Mixed CJK+Latin user, mixed title
|
||||
assert _title_language_mismatch(
|
||||
"prores raw是否能选择压缩?", "ProRes RAW 压缩选项与 BRAW 对比"
|
||||
) is False
|
||||
|
||||
|
||||
def test_cjk_start_pure_latin_title_still_rejected():
|
||||
"""A purely Latin title for a CJK conversation is still a genuine drift."""
|
||||
from api.streaming import _title_language_mismatch
|
||||
|
||||
assert _title_language_mismatch("如何修复这个错误问题", "Fixing the Bug") is True
|
||||
assert _title_language_mismatch("如何修复这个错误问题", "Python Error Guide") is True
|
||||
|
||||
|
||||
# ── regression guards: legitimate same-script titles must NOT be rejected ───
|
||||
|
||||
def test_english_start_english_title_allowed():
|
||||
|
||||
@@ -534,6 +534,7 @@ def test_poll_loop_skips_projection_when_unchanged(tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(gw, "_get_agent_sessions_from_db", fake_projection)
|
||||
w = gw.GatewayWatcher(state_db_path=db)
|
||||
w.subscribe() # Idle watchers deliberately do not poll without observers.
|
||||
|
||||
assert w._poll_once(now=1.0) is True
|
||||
assert projected == [True]
|
||||
|
||||
@@ -1,193 +1,398 @@
|
||||
"""Regression test for #3718 -- /api/models/live skips probe for custom providers.
|
||||
"""Handler-level regression tests for /api/models/live custom-provider probing.
|
||||
|
||||
When a custom provider (``custom_providers`` entry in config.yaml) has a
|
||||
``model:`` field but no ``models:`` allowlist, the live endpoint previously
|
||||
populated ``ids`` from the config entry and then skipped the live ``/v1/models``
|
||||
probe (guarded by ``if not ids``). The fix collects config-specified model IDs
|
||||
in a separate ``_config_ids`` list so the live fetch always runs, and merges
|
||||
config entries as a fallback after the fetch.
|
||||
These tests invoke the real ``routes._handle_live_models`` and inspect the
|
||||
emitted ``models`` payload. Earlier revisions of this file re-implemented the
|
||||
filtering loop locally, which meant they passed unchanged even with the
|
||||
production filter deleted (a vacuous oracle). Every case here must fail if the
|
||||
allowlist gating in ``api/routes.py`` regresses.
|
||||
|
||||
Covered shapes:
|
||||
* native ``models`` list → live catalog filtered
|
||||
* JSON-array-string ``models`` → live catalog filtered (#7120 class)
|
||||
* Python-literal-string ``models`` → live catalog filtered
|
||||
* singular ``model`` only, no ``models`` → live catalog NOT gated
|
||||
* malformed serialized ``models`` → safe, treated as scalar name
|
||||
* allowlisted model absent from live catalog → appended
|
||||
* live probe failure → falls back to config ids
|
||||
* dict-shaped ``models`` (Agent setup) → live catalog NOT gated (per-model
|
||||
metadata, not an allowlist)
|
||||
* dict-shaped ``models`` + ``discover_models:
|
||||
false`` → pinned to dict keys (explicit opt-out)
|
||||
* ``models_discovered: true`` catalog → live catalog NOT gated (discovery
|
||||
metadata is not an allowlist)
|
||||
* discovered + ``discover_models: false`` → still pinned (explicit opt-out)
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import sys
|
||||
import types
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
ROUTES_PY = REPO / "api" / "routes.py"
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLiveModelsCustomProviderProbe(unittest.TestCase):
|
||||
"""Live endpoint must probe the upstream /v1/models even when config has entries."""
|
||||
def _install_provider_model_ids(monkeypatch, fn):
|
||||
"""Stub hermes_cli.models.provider_model_ids (returns [] for custom:*)."""
|
||||
hermes_cli = types.ModuleType("hermes_cli")
|
||||
hermes_cli.__path__ = []
|
||||
models = types.ModuleType("hermes_cli.models")
|
||||
models.provider_model_ids = fn
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", models)
|
||||
|
||||
def test_custom_provider_with_model_field_probes_upstream(self):
|
||||
"""When a custom provider has model: in config, live fetch must still run.
|
||||
|
||||
Before the fix, _custom_provider_model_ids() populated ids=["assistant"],
|
||||
and the `if not ids` guard prevented the upstream /v1/models probe from
|
||||
running. The endpoint returned only ["assistant"] instead of the full
|
||||
upstream catalog.
|
||||
class _FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
We test the fix by simulating the logic directly: config provides
|
||||
model IDs, the upstream probe returns a different set, and the result
|
||||
must contain BOTH the live models and the config fallback.
|
||||
"""
|
||||
# Simulate config-specified model IDs
|
||||
_config_ids = ["assistant"]
|
||||
def __exit__(self, *_exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
# Simulate upstream /v1/models response
|
||||
live_models = [
|
||||
"assistant",
|
||||
"assistant-pro",
|
||||
"assistant-zdr",
|
||||
"gpt-5.5:pt",
|
||||
"claude-sonnet-4.6:pt",
|
||||
]
|
||||
|
||||
# Simulate the merge logic from the fix:
|
||||
# if ids: merge config entries not in live set
|
||||
# else: fall back to config-only list
|
||||
ids = list(live_models) # live fetch succeeded
|
||||
_live_set = set(ids)
|
||||
for _cid in _config_ids:
|
||||
if _cid not in _live_set:
|
||||
ids.append(_cid)
|
||||
|
||||
# "assistant" from config should not duplicate the live result
|
||||
self.assertEqual(ids, live_models)
|
||||
# All live models must be present
|
||||
for m in live_models:
|
||||
self.assertIn(m, ids)
|
||||
|
||||
def test_custom_provider_falls_back_to_config_when_probe_fails(self):
|
||||
"""When the upstream probe fails, config entries must be used as fallback."""
|
||||
_config_ids = ["assistant", "custom-only-model"]
|
||||
|
||||
# Simulate failed live fetch: ids stays empty
|
||||
ids = []
|
||||
|
||||
# Fallback logic from the fix
|
||||
if not ids:
|
||||
ids = list(_config_ids)
|
||||
|
||||
self.assertEqual(ids, ["assistant", "custom-only-model"])
|
||||
|
||||
def test_config_only_model_appended_when_not_in_live_results(self):
|
||||
"""Config-specified models not in the live results must be appended."""
|
||||
_config_ids = ["assistant", "local-finetune"]
|
||||
live_models = ["assistant", "assistant-pro", "gpt-5.5:pt"]
|
||||
|
||||
ids = list(live_models)
|
||||
_live_set = set(ids)
|
||||
for _cid in _config_ids:
|
||||
if _cid not in _live_set:
|
||||
ids.append(_cid)
|
||||
|
||||
# "assistant" is in both, "local-finetune" is config-only
|
||||
self.assertIn("local-finetune", ids)
|
||||
self.assertIn("assistant-pro", ids)
|
||||
self.assertEqual(ids.count("assistant"), 1, "assistant must not be duplicated")
|
||||
|
||||
def test_live_fetch_guard_no_longer_checks_ids(self):
|
||||
"""The live fetch block must not be guarded by `if not ids`.
|
||||
|
||||
This is a structural regression guard: the fix replaced
|
||||
`if not ids and (provider == "custom" ...)` with
|
||||
`if provider == "custom" ...` so the probe always runs regardless
|
||||
of whether config entries populated ids.
|
||||
"""
|
||||
source = ROUTES_PY.read_text(encoding="utf-8")
|
||||
|
||||
# Find the "Always try live fetch" comment (added by the fix)
|
||||
marker = "Always try live fetch for custom providers"
|
||||
self.assertIn(marker, source, (
|
||||
"routes.py must contain the 'Always try live fetch' comment (#3718)"
|
||||
))
|
||||
|
||||
# Extract the block between the marker and the next major section
|
||||
marker_pos = source.find(marker)
|
||||
next_section = source.find("OpenAI-compat live fetch fallback", marker_pos)
|
||||
self.assertNotEqual(next_section, -1, "could not find next section marker")
|
||||
block = source[marker_pos:next_section]
|
||||
|
||||
# The old `if not ids and` guard must NOT appear in this block
|
||||
self.assertNotIn("if not ids and", block, (
|
||||
"Live fetch for custom providers must not be guarded by 'if not ids' (#3718)"
|
||||
))
|
||||
|
||||
def test_mocked_live_fetch_returns_full_catalog_plus_config_entry(self):
|
||||
"""Integration test: mock urlopen and exercise the real fetch/parse/merge path.
|
||||
|
||||
This test patches urllib.request.urlopen to return a fake /v1/models
|
||||
response, then runs the same fetch+merge logic that the live handler
|
||||
uses, verifying that config entries are merged and live models are
|
||||
included.
|
||||
"""
|
||||
fake_models = {"data": [
|
||||
{"id": "assistant"},
|
||||
{"id": "assistant-pro"},
|
||||
{"id": "gpt-5.5:pt"},
|
||||
]}
|
||||
fake_body = json.dumps(fake_models).encode("utf-8")
|
||||
fake_resp = mock.MagicMock()
|
||||
fake_resp.read.return_value = fake_body
|
||||
fake_resp.__enter__ = mock.MagicMock(return_value=fake_resp)
|
||||
fake_resp.__exit__ = mock.MagicMock(return_value=False)
|
||||
|
||||
_config_ids = ["assistant", "local-only"]
|
||||
|
||||
with mock.patch("urllib.request.urlopen", return_value=fake_resp):
|
||||
# Replicate the fetch+parse logic from routes.py
|
||||
def _install_live_catalog(monkeypatch, routes, catalog, *, fail=False):
|
||||
"""Stub the upstream /v1/models probe used by the custom-provider branch."""
|
||||
import urllib.request
|
||||
ids = []
|
||||
_req = urllib.request.Request(
|
||||
"http://localhost:4000/v1/models",
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
|
||||
requested: list[str] = []
|
||||
|
||||
def _fake_urlopen(req, timeout=None):
|
||||
requested.append(getattr(req, "full_url", str(req)))
|
||||
if fail:
|
||||
raise OSError("probe failed")
|
||||
body = {"data": [{"id": mid} for mid in catalog]}
|
||||
return _FakeResponse(json.dumps(body).encode())
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
|
||||
return requested
|
||||
|
||||
|
||||
def _run_live_models(monkeypatch, routes, custom_provider, *, catalog, fail=False):
|
||||
"""Invoke the real handler for provider=custom:test-gateway."""
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
routes._clear_live_models_cache()
|
||||
monkeypatch.setattr(
|
||||
routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload
|
||||
)
|
||||
with urllib.request.urlopen(_req, timeout=5) as _resp:
|
||||
_body = json.loads(_resp.read())
|
||||
monkeypatch.setattr(config, "_resolve_provider_alias", lambda provider: provider)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
|
||||
if isinstance(_body, dict):
|
||||
_data = _body.get("data", [])
|
||||
if isinstance(_data, list):
|
||||
ids = [m.get("id", "") for m in _data if m.get("id")]
|
||||
config_data = {
|
||||
"model": {"provider": "custom:test-gateway"},
|
||||
"custom_providers": [custom_provider],
|
||||
}
|
||||
monkeypatch.setattr(config, "get_config", lambda: config_data)
|
||||
monkeypatch.setattr(routes, "cfg", config_data, raising=False)
|
||||
|
||||
# Apply the merge logic from the fix
|
||||
if ids:
|
||||
_live_set = set(ids)
|
||||
for _cid in _config_ids:
|
||||
if _cid not in _live_set:
|
||||
ids.append(_cid)
|
||||
else:
|
||||
ids = list(_config_ids)
|
||||
# provider_model_ids() returns [] for custom:* → drives the custom branch.
|
||||
_install_provider_model_ids(monkeypatch, lambda provider: [])
|
||||
requested = _install_live_catalog(monkeypatch, routes, catalog, fail=fail)
|
||||
|
||||
# Live models must all be present
|
||||
self.assertIn("assistant", ids)
|
||||
self.assertIn("assistant-pro", ids)
|
||||
self.assertIn("gpt-5.5:pt", ids)
|
||||
# Config-only entry must be appended
|
||||
self.assertIn("local-only", ids)
|
||||
# No duplicates
|
||||
self.assertEqual(ids.count("assistant"), 1)
|
||||
|
||||
def test_timeout_uses_config_constant(self):
|
||||
"""The live fetch must use CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, not a hardcoded value."""
|
||||
source = ROUTES_PY.read_text(encoding="utf-8")
|
||||
|
||||
# Find the custom-provider live fetch block
|
||||
marker = "Always try live fetch for custom providers"
|
||||
marker_pos = source.find(marker)
|
||||
next_section = source.find("OpenAI-compat live fetch fallback", marker_pos)
|
||||
block = source[marker_pos:next_section]
|
||||
|
||||
# Must use the constant, not a bare number
|
||||
self.assertIn("CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS", block, (
|
||||
"Live fetch timeout must use CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS "
|
||||
"instead of a hardcoded value (#3718 review feedback)"
|
||||
))
|
||||
self.assertNotIn("timeout=8", block, (
|
||||
"Live fetch must not use hardcoded timeout=8 (#3718 review feedback)"
|
||||
))
|
||||
parsed = urlparse("/api/models/live?provider=custom:test-gateway")
|
||||
payload = routes._handle_live_models(object(), parsed)
|
||||
return payload, requested
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
def _ids(payload):
|
||||
return [m["id"] for m in payload.get("models", [])]
|
||||
|
||||
|
||||
_LIVE_CATALOG = [
|
||||
"chat-a",
|
||||
"chat-b",
|
||||
"image-gen-1", # non-chat noise the picker must not surface
|
||||
"audio-tts-1",
|
||||
]
|
||||
|
||||
_BASE_PROVIDER = {
|
||||
"name": "Test Gateway",
|
||||
"base_url": "https://gateway.example/v1",
|
||||
"api_key": "sk-test",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"models_value",
|
||||
[
|
||||
pytest.param(["chat-a", "chat-b"], id="native-list"),
|
||||
pytest.param('["chat-a","chat-b"]', id="json-array-string"),
|
||||
pytest.param("['chat-a', 'chat-b']", id="python-literal-string"),
|
||||
pytest.param(
|
||||
[{"id": "chat-a"}, {"model": "chat-b"}], id="list-of-dicts"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_allowlist_filters_live_catalog(monkeypatch, models_value):
|
||||
"""An explicit plural allowlist must trim the live catalog to itself.
|
||||
|
||||
The JSON-array-string and Python-literal cases are the regression: those
|
||||
shapes are what ``hermes config set`` / JSON-mode editor saves persist, and
|
||||
a dict/list-only parser drops them to ``[]`` → read as "no allowlist" → the
|
||||
full upstream catalog floods the picker.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, models=models_value)
|
||||
payload, requested = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
|
||||
assert requested, "live /v1/models probe was never attempted"
|
||||
assert _ids(payload) == ["chat-a", "chat-b"]
|
||||
assert "image-gen-1" not in _ids(payload)
|
||||
assert "audio-tts-1" not in _ids(payload)
|
||||
|
||||
|
||||
def test_dict_models_without_discover_false_does_not_gate(monkeypatch):
|
||||
"""A dict-shaped ``models`` is per-model metadata — NOT an allowlist (#7165).
|
||||
|
||||
Hermes Agent's setup flow and ``hermes_cli/model_switch.py::_save_custom_provider``
|
||||
persist per-model metadata as a mapping, e.g. ``{chat-a: {context_length: 128000}}``.
|
||||
Treating its keys as an allowlist would collapse the live picker to the saved
|
||||
default while the CLI live-probe shows the full catalog. Without an explicit
|
||||
``discover_models: false`` opt-out the dict must NOT gate — full live catalog wins.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(
|
||||
_BASE_PROVIDER,
|
||||
models={"chat-a": {"context_length": 128000}, "chat-b": {"context_length": 32000}},
|
||||
)
|
||||
payload, requested = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
|
||||
assert requested, "live /v1/models probe was never attempted"
|
||||
assert _ids(payload) == _LIVE_CATALOG, (
|
||||
"dict-shaped models is per-model metadata, not an allowlist — the "
|
||||
"full live catalog must be surfaced"
|
||||
)
|
||||
|
||||
|
||||
def test_dict_models_with_discover_false_pins_to_keys(monkeypatch):
|
||||
"""``discover_models: false`` opts a dict-shaped ``models`` into an allowlist.
|
||||
|
||||
The explicit opt-out (bool ``False`` or the string forms ``"false"``/``"no"``/``"0"``)
|
||||
means the dict keys are a hand-pinned allowlist and must filter the live catalog.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
for discover in (False, "false", "no", "0"):
|
||||
provider = dict(
|
||||
_BASE_PROVIDER,
|
||||
models={"chat-a": {"context_length": 128000}, "chat-b": {"context_length": 32000}},
|
||||
discover_models=discover,
|
||||
)
|
||||
payload, requested = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
assert requested, "live /v1/models probe was never attempted"
|
||||
assert _ids(payload) == ["chat-a", "chat-b"], (
|
||||
f"discover_models={discover!r} must pin the dict keys as the allowlist"
|
||||
)
|
||||
assert "image-gen-1" not in _ids(payload)
|
||||
assert "audio-tts-1" not in _ids(payload)
|
||||
|
||||
|
||||
def test_singular_model_only_does_not_gate_live_catalog(monkeypatch):
|
||||
"""``model:`` is default metadata, not an allowlist — keep full discovery."""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, model="chat-a")
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
|
||||
assert _ids(payload) == _LIVE_CATALOG
|
||||
|
||||
|
||||
def test_malformed_serialized_models_is_safe(monkeypatch):
|
||||
"""A broken JSON-array string must not crash or silently drop the gate.
|
||||
|
||||
``_parse_config_string_list()`` treats an undecodable value as one literal
|
||||
name, so the allowlist stays non-empty and the filter still runs.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, models='["chat-a", "chat-b"') # missing ]
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
|
||||
# Treated as a single (unmatched) name → appended, catalog not flooded.
|
||||
assert "image-gen-1" not in _ids(payload)
|
||||
assert "audio-tts-1" not in _ids(payload)
|
||||
|
||||
|
||||
def test_allowlisted_model_missing_from_live_is_appended(monkeypatch):
|
||||
"""A declared model the upstream didn't return must still be selectable."""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, models=["chat-a", "chat-offline"])
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
|
||||
assert _ids(payload) == ["chat-a", "chat-offline"]
|
||||
|
||||
|
||||
def test_contradictory_singular_model_outside_plural_allowlist_is_ignored(monkeypatch):
|
||||
"""A singular default outside the plural allowlist must not leak into the picker.
|
||||
|
||||
Paired test: whether the live probe succeeds or fails, only the explicit
|
||||
plural allowlist is exposed.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, model="chat-default", models=["chat-a"])
|
||||
|
||||
# 1. Successful live fetch -> only plural allowlist
|
||||
payload_success, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG, fail=False
|
||||
)
|
||||
assert _ids(payload_success) == ["chat-a"]
|
||||
|
||||
# 2. Failed live fetch -> still only plural allowlist (not chat-default)
|
||||
payload_fail, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG, fail=True
|
||||
)
|
||||
assert _ids(payload_fail) == ["chat-a"]
|
||||
|
||||
|
||||
def test_probe_failure_without_allowlist_falls_back_to_singular_model(monkeypatch):
|
||||
"""When no plural allowlist is configured and probe fails, singular model is used."""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, model="chat-default")
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG, fail=True
|
||||
)
|
||||
assert _ids(payload) == ["chat-default"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"models_value",
|
||||
[
|
||||
pytest.param([], id="native-empty-list"),
|
||||
pytest.param("[]", id="serialized-empty-list"),
|
||||
pytest.param({}, id="empty-mapping"),
|
||||
pytest.param(" ", id="blank-scalar"),
|
||||
],
|
||||
)
|
||||
def test_empty_allowlist_is_treated_as_not_configured(monkeypatch, models_value):
|
||||
"""An empty ``models`` value must NOT be read as "allow nothing".
|
||||
|
||||
Pins a deliberate semantic decision. Gating the filter on *declared-ness*
|
||||
rather than on a non-empty allowlist would make ``models: []`` emit an
|
||||
EMPTY picker, leaving the provider unselectable — a harder failure than
|
||||
surfacing a few extra models, and unrecoverable from the UI because
|
||||
``custom_providers`` has no WebUI write path (hand-edited config.yaml only).
|
||||
There is also no deny-all use case: a provider the user wants hidden is
|
||||
removed from ``custom_providers`` outright.
|
||||
|
||||
If someone later "fixes" the empty case into a deny-all gate, this test
|
||||
fails and forces the trade-off to be re-argued rather than silently shipped.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(_BASE_PROVIDER, models=models_value)
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
|
||||
)
|
||||
|
||||
assert _ids(payload) == _LIVE_CATALOG, (
|
||||
"empty allowlist must fall through to full live discovery, "
|
||||
"never collapse the picker to zero models"
|
||||
)
|
||||
assert _ids(payload), "picker must never be emptied by an empty allowlist"
|
||||
|
||||
|
||||
def test_models_discovered_catalog_is_not_an_allowlist(monkeypatch):
|
||||
"""``models_discovered: true`` marks ``models:`` as discovery metadata (#7165).
|
||||
|
||||
Hermes persists *discovery results* back into config as ``models: {...}``
|
||||
plus ``models_discovered: true``. That mapping is a snapshot of what the
|
||||
gateway exposed at discovery time — NOT a hand-curated allowlist. Treating
|
||||
it as one permanently pins the live catalog to the first-discovery set, so
|
||||
a model the user later pulls into LM Studio / Ollama is silently dropped
|
||||
from ``/api/models/live`` (the exact regression the gate reproduced: merge
|
||||
base returned ``saved-a``, ``saved-b`` AND ``new-live-c``; the pinned head
|
||||
dropped ``new-live-c``).
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(
|
||||
_BASE_PROVIDER,
|
||||
models={
|
||||
"saved-a": {"context_length": 128000},
|
||||
"saved-b": {"context_length": 32000},
|
||||
},
|
||||
models_discovered=True,
|
||||
)
|
||||
payload, requested = _run_live_models(
|
||||
monkeypatch,
|
||||
routes,
|
||||
provider,
|
||||
catalog=["saved-a", "saved-b", "new-live-c"],
|
||||
)
|
||||
|
||||
assert requested, "live /v1/models probe was never attempted"
|
||||
assert _ids(payload) == ["saved-a", "saved-b", "new-live-c"], (
|
||||
"a discovered catalog must not gate the live probe — models that "
|
||||
"appear upstream after discovery (new-live-c) must reach the picker"
|
||||
)
|
||||
|
||||
|
||||
def test_discovered_catalog_with_discover_models_false_still_pins(monkeypatch):
|
||||
"""``discover_models: false`` re-pins a discovered catalog (control).
|
||||
|
||||
The explicit opt-out wins over ``models_discovered: true`` — same rule as
|
||||
the ``/api/models`` catalog path (`api.config._provider_discover_allowed`).
|
||||
Filtering to the configured set must survive the discovered-catalog fix.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(
|
||||
_BASE_PROVIDER,
|
||||
models={
|
||||
"saved-a": {"context_length": 128000},
|
||||
"saved-b": {"context_length": 32000},
|
||||
},
|
||||
models_discovered=True,
|
||||
discover_models=False,
|
||||
)
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch,
|
||||
routes,
|
||||
provider,
|
||||
catalog=["saved-a", "saved-b", "new-live-c"],
|
||||
)
|
||||
|
||||
assert _ids(payload) == ["saved-a", "saved-b"]
|
||||
|
||||
|
||||
def test_discovered_catalog_probe_failure_falls_back_to_saved_models(monkeypatch):
|
||||
"""Discovered + failed probe → saved models, not an empty picker (control).
|
||||
|
||||
Releasing the allowlist must not empty the fallback: when the live probe
|
||||
fails, the previously discovered ``models:`` mapping is still the best
|
||||
answer and must be served from ``_config_ids``.
|
||||
"""
|
||||
import api.routes as routes
|
||||
|
||||
provider = dict(
|
||||
_BASE_PROVIDER,
|
||||
models={
|
||||
"saved-a": {"context_length": 128000},
|
||||
"saved-b": {"context_length": 32000},
|
||||
},
|
||||
models_discovered=True,
|
||||
)
|
||||
payload, _ = _run_live_models(
|
||||
monkeypatch, routes, provider, catalog=[], fail=True
|
||||
)
|
||||
|
||||
assert _ids(payload) == ["saved-a", "saved-b"]
|
||||
@@ -74,28 +74,38 @@ def _messages_indexes(path):
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def test_prime_creates_missing_index(tmp_path):
|
||||
"""A db missing idx_messages_session gets it primed on the first scan."""
|
||||
def test_listing_does_not_create_missing_index(tmp_path, monkeypatch):
|
||||
"""A missing index must never turn a sidebar read into a schema write.
|
||||
|
||||
CREATE INDEX on a multi-GiB ``messages`` table holds the SQLite writer lock
|
||||
for minutes; index maintenance belongs to the explicit drained tool
|
||||
(``scripts/ensure_state_db_read_indexes.py``), not to a listing.
|
||||
"""
|
||||
db = tmp_path / "state.db"
|
||||
_full_schema_db(db)
|
||||
assert "idx_messages_session" not in _messages_indexes(db)
|
||||
connect_calls = []
|
||||
real_connect = agent_sessions.sqlite3.connect
|
||||
|
||||
def recording_connect(*args, **kwargs):
|
||||
connect_calls.append((args, kwargs))
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording_connect)
|
||||
|
||||
rows = agent_sessions.read_importable_agent_session_rows(
|
||||
db, limit=20, exclude_sources=None
|
||||
)
|
||||
# Snapshot before the verifier below opens its own (non-listing) handle.
|
||||
listing_connect_calls = list(connect_calls)
|
||||
|
||||
# Listing still returns the sessions ...
|
||||
assert {r["id"] for r in rows} == {"sess0", "sess1", "sess2"}
|
||||
# ... and the index now exists on (session_id, timestamp).
|
||||
assert "idx_messages_session" in _messages_indexes(db)
|
||||
conn = sqlite3.connect(str(db))
|
||||
try:
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE name='idx_messages_session'"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
assert "session_id" in sql and "timestamp" in sql
|
||||
# ... without mutating the schema or opening a write-capable connection.
|
||||
assert "idx_messages_session" not in _messages_indexes(db)
|
||||
assert len(listing_connect_calls) == 1
|
||||
assert "mode=ro" in str(listing_connect_calls[0][0][0])
|
||||
assert listing_connect_calls[0][1].get("uri") is True
|
||||
|
||||
|
||||
def test_prime_is_noop_when_index_exists(tmp_path):
|
||||
|
||||
@@ -41,7 +41,17 @@ class _FakeHandler:
|
||||
|
||||
def _capture(monkeypatch):
|
||||
cap = {}
|
||||
monkeypatch.setattr(routes, "j", lambda h, o: (cap.__setitem__("ok", o), True)[1])
|
||||
# #7710: the real ``j`` signature is ``j(handler, payload, status=200,
|
||||
# extra_headers=None, *, pretty=True)``; the mock has to accept the
|
||||
# status keyword so the helper's 409 ``session_profile_mismatch``
|
||||
# payload can be captured here. Capture the STATUS too — swallowing it
|
||||
# into ``*_`` would let a wrong status (200/404) pass this test.
|
||||
def _fake_j(h, o, status=200, *_, **__):
|
||||
cap["ok"] = o
|
||||
cap["ok_status"] = status
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(routes, "j", _fake_j)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"bad",
|
||||
@@ -72,7 +82,10 @@ class _FakeSession:
|
||||
|
||||
def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch):
|
||||
"""Unqualified request (active profile=default) for a session stored under a
|
||||
foreign profile must 404 — not read or refresh the foreign session."""
|
||||
foreign profile must yield 409 ``session_profile_mismatch`` (so the client
|
||||
can offer to switch to it per #5419) — not 404, which would mask a
|
||||
legitimate owned-by-other-profile session as missing and trigger a
|
||||
destructive self-heal in the frontend (#7710)."""
|
||||
foreign = _FakeSession("foreign_existing_001", "other")
|
||||
monkeypatch.setattr(routes.Session, "load", staticmethod(lambda sid: foreign))
|
||||
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
||||
@@ -88,8 +101,14 @@ def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch
|
||||
{"session_id": "foreign_existing_001"},
|
||||
)
|
||||
|
||||
assert "bad" in cap, f"expected 404, got {cap}"
|
||||
assert cap["bad"][1] == 404
|
||||
assert "ok" in cap, f"expected 409 session_profile_mismatch, got {cap}"
|
||||
assert cap["ok_status"] == 409, f"expected status 409, got {cap.get('ok_status')}"
|
||||
assert cap["ok"] == {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": "foreign_existing_001",
|
||||
"profile": "other",
|
||||
}
|
||||
|
||||
|
||||
def test_import_cli_existing_same_profile_still_refreshes(monkeypatch):
|
||||
|
||||
@@ -536,7 +536,7 @@ def test_source_filtered_cache_preserves_hidden_bucket_runtime_state():
|
||||
remember_source_fn = _extract_function(src, "_rememberSessionListSource")
|
||||
remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState")
|
||||
remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot")
|
||||
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
|
||||
purge_fn = _extract_function(src, "_hasOwnedOpenLiveStream") + "\n" + _extract_function(src, "_purgeStaleInflightEntries")
|
||||
mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions")
|
||||
script = f"""
|
||||
global._allSessions = [{{
|
||||
@@ -654,7 +654,7 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch):
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_scope_mismatch_error_path_respects_sidebar_source():
|
||||
src = SESSIONS_JS.read_text(encoding="utf-8")
|
||||
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
|
||||
purge_fn = _extract_function(src, "_hasOwnedOpenLiveStream") + "\n" + _extract_function(src, "_purgeStaleInflightEntries")
|
||||
clear_fn = _extract_function(src, "_clearSessionSourceTabCounts")
|
||||
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
|
||||
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
|
||||
|
||||
@@ -507,7 +507,18 @@ def test_session_route_blocks_hidden_sessions_before_replay_or_live_attach(monke
|
||||
handler = _FakeHandler()
|
||||
routes.handle_get(handler, urlparse("/api/sessions/hidden_session/events"))
|
||||
|
||||
assert cap["bad"] == ("Session not found", 404)
|
||||
# #7710: the generic request-guard now mirrors the detail-load
|
||||
# endpoint's contract — a session owned by a KNOWN other profile
|
||||
# yields 409 ``session_profile_mismatch`` so the client can offer
|
||||
# to switch to it (#5419). The 404 self-heal path is preserved for
|
||||
# the None-profile (unknown/legacy) case.
|
||||
assert cap.get("status") == 409, cap
|
||||
assert cap.get("ok") == {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": "hidden_session",
|
||||
"profile": "other",
|
||||
}
|
||||
|
||||
|
||||
def test_session_route_live_delivery_skips_replayed_active_run_items(monkeypatch):
|
||||
@@ -583,6 +594,64 @@ def test_session_route_live_delivery_skips_replayed_active_run_items(monkeypatch
|
||||
assert stream.unsubscribed is True
|
||||
|
||||
|
||||
def test_session_route_replay_skips_metering_rows(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self):
|
||||
self.q = queue.Queue()
|
||||
self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:2"))
|
||||
self.unsubscribed = False
|
||||
|
||||
def subscribe_with_snapshot(self):
|
||||
return self.q, {"last_event_id": "run_active:1", "offline_buffered_events": 1}
|
||||
|
||||
def unsubscribe(self, q):
|
||||
self.unsubscribed = q is self.q
|
||||
|
||||
stream = _FakeStream()
|
||||
monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_session",
|
||||
lambda sid, metadata_only=False: SimpleNamespace(
|
||||
session_id=sid,
|
||||
compact=lambda **_kwargs: {"session_id": sid, "title": "Session"},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active")
|
||||
monkeypatch.setattr(routes, "STREAMS", {"run_active": stream})
|
||||
import api.config as config
|
||||
monkeypatch.setattr(config, "STREAMS", {"run_active": stream})
|
||||
# Both the pre-attach replay and the post-attach reconciliation draw from the
|
||||
# same journal; a metering row among them must never reach the SSE body.
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_session_run_events",
|
||||
lambda *_args, **_kwargs: {
|
||||
"status": "ok",
|
||||
"events": [
|
||||
{"run_id": "run_prev", "seq": 1, "event": "token", "payload": {"text": "replayed"}, "event_id": "run_prev:1"},
|
||||
{"run_id": "run_prev", "seq": 2, "event": "metering", "payload": {"tps": 47.3}, "event_id": "run_prev:2"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
handler = _FakeHandler()
|
||||
routes._handle_session_sse_stream_for_session(
|
||||
handler,
|
||||
urlparse("/api/sessions/session_1/events?after_event_id=run_prev:0"),
|
||||
"session_1",
|
||||
)
|
||||
|
||||
body = handler.wfile.getvalue().decode("utf-8")
|
||||
assert "id: run_prev:1\n" in body
|
||||
assert "event: token\n" in body
|
||||
assert "event: metering\n" not in body
|
||||
assert "id: run_prev:2\n" not in body
|
||||
assert stream.unsubscribed is True
|
||||
|
||||
|
||||
def test_session_route_breaks_on_terminal_even_when_reconciliation_replayed_it(monkeypatch):
|
||||
"""CORE regression (#5677 gate r3): if reconciliation replays the active run's
|
||||
TERMINAL event at the subscribe cutoff, the live queue's copy is deduped — but
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""#5311 follow-up: OpenCode Go uses the live Hermes CLI catalog again.
|
||||
|
||||
Background: #5611 made WebUI skip the live ``/v1/models`` probe for OpenCode Go
|
||||
because the probe returned public-catalog models not enabled on the Go tier.
|
||||
That premise no longer holds: Hermes core v0.20.5 (commit fcbd1076, tag
|
||||
v2026.8.19) ships a Go-specific provider profile that probes
|
||||
``https://opencode.ai/zen/go/v1/models`` directly and merges results
|
||||
live-first with its curated Go list (hermes_cli/models.py, #49129). With the
|
||||
correct Go-tier endpoint in core, WebUI should delegate like every other
|
||||
provider (#1240): live catalog first, the curated static ``_PROVIDER_MODELS``
|
||||
list as offline fallback only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
|
||||
_PROVIDER_ENV_VARS = (
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def _scrub_provider_env(monkeypatch):
|
||||
for name in _PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def _install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
*,
|
||||
provider_id: str,
|
||||
live_ids,
|
||||
core_version: str = "0.20.5",
|
||||
raise_on_lookup: bool = False,
|
||||
):
|
||||
"""Install a hermes_cli stub that reports one authenticated provider."""
|
||||
fake_pkg = types.ModuleType("hermes_cli")
|
||||
fake_pkg.__path__ = []
|
||||
fake_pkg.__version__ = core_version
|
||||
|
||||
fake_models = types.ModuleType("hermes_cli.models")
|
||||
fake_models.list_available_providers = lambda: [
|
||||
{"id": provider_id, "authenticated": True}
|
||||
]
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def provider_model_ids(pid):
|
||||
calls.append(pid)
|
||||
if raise_on_lookup:
|
||||
raise RuntimeError("simulated provider_model_ids failure")
|
||||
return list(live_ids) if pid == provider_id else []
|
||||
|
||||
fake_models.provider_model_ids = provider_model_ids
|
||||
|
||||
fake_auth = types.ModuleType("hermes_cli.auth")
|
||||
|
||||
def get_auth_status(pid):
|
||||
if pid == provider_id:
|
||||
return {"logged_in": True, "key_source": ""}
|
||||
return {"logged_in": False, "key_source": ""}
|
||||
|
||||
fake_auth.get_auth_status = get_auth_status
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
|
||||
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||||
config.invalidate_models_cache()
|
||||
return calls
|
||||
|
||||
|
||||
def _configure(monkeypatch, tmp_path, *, provider: str, providers: dict | None = None):
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml")
|
||||
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"cfg",
|
||||
{
|
||||
"model": {"provider": provider, "default": ""},
|
||||
"providers": providers if providers is not None else {},
|
||||
"fallback_providers": [],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(config, "_cfg_mtime", 0.0)
|
||||
monkeypatch.setattr(config, "_cfg_path", config._get_config_path(), raising=False)
|
||||
config.invalidate_models_cache()
|
||||
|
||||
|
||||
def _provider_group(result: dict, provider_id: str) -> dict:
|
||||
return next(g for g in result["groups"] if g.get("provider_id") == provider_id)
|
||||
|
||||
|
||||
def _ids(group: dict) -> list[str]:
|
||||
return [m.get("id") for m in group.get("models", [])]
|
||||
|
||||
|
||||
def test_opencode_go_probes_live_catalog(monkeypatch, tmp_path):
|
||||
"""The Go picker group must come from the live Hermes CLI catalog.
|
||||
|
||||
``sentinel-go-model`` is intentionally absent from every static list. If
|
||||
WebUI special-cases OpenCode Go away from the live probe again (the #5611
|
||||
stopgap), the sentinel disappears and this test fails.
|
||||
"""
|
||||
_scrub_provider_env(monkeypatch)
|
||||
calls = _install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
provider_id="opencode-go",
|
||||
live_ids=["sentinel-go-model", "kimi-k3"],
|
||||
)
|
||||
_configure(monkeypatch, tmp_path, provider="opencode-go")
|
||||
|
||||
result = config.get_available_models()
|
||||
group = _provider_group(result, "opencode-go")
|
||||
|
||||
assert calls == ["opencode-go"]
|
||||
ids = _ids(group)
|
||||
assert "sentinel-go-model" in ids
|
||||
assert "kimi-k3" in ids
|
||||
assert all(m.get("label") for m in group["models"])
|
||||
|
||||
|
||||
def test_opencode_go_old_core_ignores_nonempty_generic_catalog(monkeypatch, tmp_path):
|
||||
"""Pre-v0.20.5 cores must not leak their generic public catalog into Go."""
|
||||
_scrub_provider_env(monkeypatch)
|
||||
calls = _install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
provider_id="opencode-go",
|
||||
live_ids=["go-ineligible-old-core-sentinel"],
|
||||
core_version="0.20.4",
|
||||
)
|
||||
_configure(monkeypatch, tmp_path, provider="opencode-go")
|
||||
|
||||
result = config.get_available_models()
|
||||
group = _provider_group(result, "opencode-go")
|
||||
|
||||
assert calls == []
|
||||
ids = _ids(group)
|
||||
assert "go-ineligible-old-core-sentinel" not in ids
|
||||
assert "kimi-k3" in ids
|
||||
|
||||
|
||||
def test_opencode_go_static_fallback_when_probe_fails(monkeypatch, tmp_path):
|
||||
"""Offline / CLI failure must fall back to the curated static list."""
|
||||
_scrub_provider_env(monkeypatch)
|
||||
calls = _install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
provider_id="opencode-go",
|
||||
live_ids=[],
|
||||
raise_on_lookup=True,
|
||||
)
|
||||
_configure(monkeypatch, tmp_path, provider="opencode-go")
|
||||
|
||||
result = config.get_available_models()
|
||||
group = _provider_group(result, "opencode-go")
|
||||
|
||||
assert calls == ["opencode-go"]
|
||||
ids = _ids(group)
|
||||
assert "sentinel-go-model" not in ids
|
||||
assert "glm-5" in ids
|
||||
assert "kimi-k2.5" in ids
|
||||
|
||||
|
||||
def test_opencode_go_config_allowlist_still_wins(monkeypatch, tmp_path):
|
||||
"""An explicit ``providers.opencode-go.models`` allowlist remains the
|
||||
local source of truth (#644) and is not bypassed by the live probe."""
|
||||
_scrub_provider_env(monkeypatch)
|
||||
calls = _install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
provider_id="opencode-go",
|
||||
live_ids=["sentinel-go-model"],
|
||||
)
|
||||
_configure(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
provider="opencode-go",
|
||||
providers={"opencode-go": {"models": ["glm-5"]}},
|
||||
)
|
||||
|
||||
result = config.get_available_models()
|
||||
group = _provider_group(result, "opencode-go")
|
||||
|
||||
assert calls == [] # probe never consulted
|
||||
assert _ids(group) == ["glm-5"]
|
||||
|
||||
|
||||
# ── Static fallback list contract ─────────────────────────────────────
|
||||
# The remaining tests pin the offline fallback list itself to Hermes core's
|
||||
# current curated contract (hermes_cli/models_catalog_static.py, core main
|
||||
# 2026-09-10; the list originated in v0.20.5, commit fcbd1076). Core owns
|
||||
# the sync duty against the live
|
||||
# https://opencode.ai/zen/go/v1/models endpoint and
|
||||
# https://opencode.ai/docs/go/, and WebUI only mirrors. This catches drift
|
||||
# in either direction: ids core added and ids core dropped (e.g.
|
||||
# ox-alpha-free, delisted from the Go relay on 2026-09-09).
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = ROOT / "api" / "config.py"
|
||||
CONFIG = CONFIG_PATH.read_text(encoding="utf-8")
|
||||
|
||||
EXPECTED_OPENCODE_GO_MODEL_IDS = [
|
||||
"kimi-k3",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"gpt-5.6-luna",
|
||||
"grok-4.5",
|
||||
"glm-5.3",
|
||||
"glm-5.3-flash",
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"qwen3.8-max",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
"qwen3.5-plus",
|
||||
"hy3",
|
||||
"hy3-preview",
|
||||
"muse-spark-1.2-contributor",
|
||||
"muse-spark-1.3-contributor",
|
||||
]
|
||||
|
||||
|
||||
def _opencode_go_static_models():
|
||||
tree = ast.parse(CONFIG, filename=str(CONFIG_PATH))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not any(isinstance(target, ast.Name) and target.id == "_PROVIDER_MODELS" for target in node.targets):
|
||||
continue
|
||||
provider_models = ast.literal_eval(node.value)
|
||||
return provider_models["opencode-go"]
|
||||
raise AssertionError("_PROVIDER_MODELS assignment not found")
|
||||
|
||||
|
||||
def test_opencode_go_static_models_match_core_catalog_snapshot():
|
||||
models = _opencode_go_static_models()
|
||||
assert [model["id"] for model in models] == EXPECTED_OPENCODE_GO_MODEL_IDS
|
||||
|
||||
|
||||
def test_opencode_go_static_models_match_installed_core_curated_list():
|
||||
"""Exercise the actual Agent contract for the supported release pair.
|
||||
|
||||
The snapshot mirrors core main's curated catalog. A released (installed)
|
||||
core can lag that sync by a few days — e.g. v2026.9.7 still lists
|
||||
``ox-alpha-free``, which core removed on 2026-09-09 after the Go relay
|
||||
delisted it. Equality binds only once the installed core carries the
|
||||
mirrored sync; the marker for "lags the sync" is the delisted id still
|
||||
being present. Anything else that differs is real drift and must fail.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
hermes_cli = pytest.importorskip(
|
||||
"hermes_cli", reason="hermes-agent is not a WebUI test dependency"
|
||||
)
|
||||
if not config._hermes_cli_supports_opencode_go_live_catalog():
|
||||
pytest.skip(f"installed hermes-agent {hermes_cli.__version__} predates v0.20.5")
|
||||
models_mod = pytest.importorskip("hermes_cli.models")
|
||||
installed = list(models_mod._PROVIDER_MODELS["opencode-go"])
|
||||
if "ox-alpha-free" in installed:
|
||||
pytest.skip(
|
||||
"installed hermes-agent catalog predates core's 2026-09-09 sync "
|
||||
"(still lists relay-delisted 'ox-alpha-free'); equality binds "
|
||||
"once a release carrying that sync is installed"
|
||||
)
|
||||
assert [model["id"] for model in _opencode_go_static_models()] == installed
|
||||
|
||||
|
||||
def test_opencode_go_recent_additions_have_human_labels():
|
||||
labels = {model["id"]: model["label"] for model in _opencode_go_static_models()}
|
||||
assert labels["gpt-5.6-luna"] == "GPT 5.6 Luna"
|
||||
assert labels["kimi-k3"] == "Kimi K3"
|
||||
assert labels["glm-5.3"] == "GLM-5.3"
|
||||
assert labels["grok-4.5"] == "Grok 4.5"
|
||||
assert labels["qwen3.8-max"] == "Qwen3.8 Max"
|
||||
assert labels["muse-spark-1.2-contributor"] == "Muse Spark 1.2 Contributor"
|
||||
assert labels["muse-spark-1.3-contributor"] == "Muse Spark 1.3 Contributor"
|
||||
assert labels["glm-5.3-flash"] == "GLM-5.3 Flash"
|
||||
@@ -1,72 +0,0 @@
|
||||
"""#5311 / #5611: OpenCode Go's generic live probe used to return public-catalog
|
||||
models not enabled on the Go tier, so the picker intentionally falls back to a curated
|
||||
static _PROVIDER_MODELS list. Keep that curated list in sync with the public Go docs
|
||||
and documented Go models endpoint while excluding preview/free-only Zen models.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = ROOT / "api" / "config.py"
|
||||
CONFIG = CONFIG_PATH.read_text(encoding="utf-8")
|
||||
|
||||
EXPECTED_OPENCODE_GO_MODEL_IDS = [
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
"qwen3.5-plus",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"mimo-v2.5-pro",
|
||||
"mimo-v2.5",
|
||||
]
|
||||
|
||||
|
||||
def _opencode_go_static_models():
|
||||
tree = ast.parse(CONFIG, filename=str(CONFIG_PATH))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not any(isinstance(target, ast.Name) and target.id == "_PROVIDER_MODELS" for target in node.targets):
|
||||
continue
|
||||
provider_models = ast.literal_eval(node.value)
|
||||
return provider_models["opencode-go"]
|
||||
raise AssertionError("_PROVIDER_MODELS assignment not found")
|
||||
|
||||
|
||||
def test_opencode_go_skips_live_models_probe():
|
||||
# The provider-loop must special-case opencode-go to skip the old generic
|
||||
# live probe and fall through to this curated static list.
|
||||
body = CONFIG[CONFIG.index("def get_available_models"):]
|
||||
body = body[: body.index("\ndef ", 1)]
|
||||
assert 'elif pid == "opencode-go":' in body
|
||||
idx = body.index('elif pid == "opencode-go":')
|
||||
branch = body[idx: idx + 400]
|
||||
assert "_models_from_live_provider_ids" not in branch.split("else:")[0]
|
||||
|
||||
|
||||
def test_opencode_go_static_models_match_documented_endpoint_snapshot():
|
||||
# Snapshot from https://opencode.ai/zen/go/v1/models on 2026-07-07.
|
||||
# This catches stale-list regressions such as missing GLM-5.2 / MiniMax M3.
|
||||
models = _opencode_go_static_models()
|
||||
assert [model["id"] for model in models] == EXPECTED_OPENCODE_GO_MODEL_IDS
|
||||
|
||||
|
||||
def test_opencode_go_recent_additions_have_human_labels():
|
||||
labels = {model["id"]: model["label"] for model in _opencode_go_static_models()}
|
||||
assert labels["glm-5.2"] == "GLM-5.2"
|
||||
assert labels["minimax-m3"] == "MiniMax M3"
|
||||
assert labels["kimi-k2.7-code"] == "Kimi K2.7 Code"
|
||||
assert labels["qwen3.7-max"] == "Qwen3.7 Max"
|
||||
@@ -9,7 +9,6 @@ lineage-metadata reads, and the gateway-watcher fingerprint projection (a 5s
|
||||
poll), were still opening a read-WRITE connection. This shared them onto the
|
||||
same ``open_state_db_readonly`` helper.
|
||||
"""
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
|
||||
@@ -98,25 +97,25 @@ def test_helper_encodes_special_path_chars(tmp_path, monkeypatch):
|
||||
assert calls[0]["target"].endswith("?mode=ro")
|
||||
|
||||
|
||||
def test_helper_falls_back_to_writable_and_logs(tmp_path, monkeypatch, caplog):
|
||||
def test_helper_never_falls_back_to_writable(tmp_path, monkeypatch):
|
||||
"""A read-only open failure propagates; the helper never retries with a
|
||||
write-capable handle on the live WAL database."""
|
||||
db = tmp_path / "state.db"
|
||||
_make_lineage_db(db)
|
||||
real_connect = sqlite3.connect
|
||||
calls = []
|
||||
|
||||
def fail_read_only(target, *args, **kwargs):
|
||||
if kwargs.get("uri"):
|
||||
calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))})
|
||||
raise sqlite3.OperationalError("synthetic read-only URI failure")
|
||||
return real_connect(target, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only)
|
||||
with caplog.at_level(logging.WARNING, logger="api.agent_sessions"):
|
||||
conn = open_state_db_readonly(db)
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 2
|
||||
finally:
|
||||
conn.close()
|
||||
assert "read-only open failed" in caplog.text
|
||||
assert "synthetic read-only URI failure" in caplog.text
|
||||
with pytest.raises(sqlite3.OperationalError, match="synthetic read-only URI failure"):
|
||||
open_state_db_readonly(db)
|
||||
|
||||
# Exactly one attempt, and it was the read-only URI form.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["uri"] is True
|
||||
assert calls[0]["target"].endswith("?mode=ro")
|
||||
|
||||
|
||||
def test_helper_raises_on_missing_db_instead_of_creating_a_ghost(tmp_path):
|
||||
|
||||
@@ -6,13 +6,12 @@ re-run a defensive ``CREATE INDEX`` self-heal on every sidebar build. Holding a
|
||||
write-capable handle while the agent streams into the same DB adds needless
|
||||
checkpoint/lock surface.
|
||||
|
||||
The listing path now opens the DB read-only (``file:...?mode=ro``) and only
|
||||
self-heals a missing ``idx_messages_session`` through a SEPARATE short-lived
|
||||
writable connection. With the index present (the normal case) the read path
|
||||
performs zero writes; when the index is missing the self-heal still runs and
|
||||
rows still come back.
|
||||
The listing path opens the DB read-only (``file:...?mode=ro``) and never
|
||||
upgrades to a writable handle: a read-only open failure propagates, and a
|
||||
missing ``idx_messages_session`` degrades to the pre-aggregated path instead
|
||||
of being self-healed. Index maintenance is an explicit drained operation
|
||||
(``scripts/ensure_state_db_read_indexes.py``).
|
||||
"""
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
import api.agent_sessions as agent_sessions
|
||||
@@ -74,6 +73,77 @@ def test_listing_opens_read_only_and_returns_rows(tmp_path, monkeypatch):
|
||||
assert "mode=ro" in calls[0]["target"]
|
||||
|
||||
|
||||
def test_listing_keeps_model_config_branch_visible_and_never_leaks_it(tmp_path, monkeypatch):
|
||||
"""#7021 re-gate: a CLI/agent branch whose _branched_from marker lives in
|
||||
model_config must stay visible even when it starts inside the 2s
|
||||
compression tolerance window, and model_config must not leak into the
|
||||
returned rows."""
|
||||
db = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
|
||||
started_at REAL, source TEXT, session_source TEXT,
|
||||
parent_session_id TEXT, ended_at REAL, end_reason TEXT,
|
||||
model_config TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions
|
||||
(id, title, model, message_count, started_at, source, session_source,
|
||||
parent_session_id, ended_at, end_reason, model_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"cli-parent", "Parent conversation", "gpt", 0, 1000.0, "cli", "cli",
|
||||
None, 1100.0, "compression", None,
|
||||
),
|
||||
)
|
||||
# An explicit Agent branch starting 1.5s BEFORE the parent's compression
|
||||
# ended_at. No session_source — the fork identity lives in model_config.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions
|
||||
(id, title, model, message_count, started_at, source, session_source,
|
||||
parent_session_id, ended_at, end_reason, model_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"cli-branch", "Branch conversation", "gpt", 0, 1098.5, "cli", "cli",
|
||||
"cli-parent", None, None, '{"_branched_from": "cli-parent"}',
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, timestamp REAL)"
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO messages (session_id, role, timestamp) VALUES (?,?,?)",
|
||||
[
|
||||
("cli-parent", "user", 1001.0),
|
||||
("cli-parent", "assistant", 1002.0),
|
||||
("cli-branch", "user", 1099.0),
|
||||
("cli-branch", "assistant", 1099.5),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_record_connects(monkeypatch)
|
||||
|
||||
out = read_importable_agent_session_rows(db, exclude_sources=None)
|
||||
out_ids = {r["id"] for r in out}
|
||||
|
||||
# The branch survives the tolerance window: it is NOT collapsed into the
|
||||
# parent lineage.
|
||||
assert "cli-branch" in out_ids
|
||||
assert "cli-parent" in out_ids
|
||||
# model_config is internal agent state — never exposed on returned rows.
|
||||
for row in out:
|
||||
assert "model_config" not in row
|
||||
|
||||
|
||||
def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch):
|
||||
db_dir = tmp_path / "state dir #1"
|
||||
db_dir.mkdir()
|
||||
@@ -92,24 +162,27 @@ def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch)
|
||||
assert calls[0]["target"].endswith("?mode=ro")
|
||||
|
||||
|
||||
def test_read_only_open_fallback_is_logged(tmp_path, monkeypatch, caplog):
|
||||
def test_read_only_open_failure_never_retries_with_writable_connection(tmp_path, monkeypatch):
|
||||
db = tmp_path / "state.db"
|
||||
_make_db(db, with_index=True)
|
||||
real_connect = sqlite3.connect
|
||||
calls = []
|
||||
|
||||
def fail_read_only(target, *args, **kwargs):
|
||||
if kwargs.get("uri"):
|
||||
calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))})
|
||||
raise sqlite3.OperationalError("synthetic read-only URI failure")
|
||||
return real_connect(target, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="api.agent_sessions"):
|
||||
out = read_importable_agent_session_rows(db, exclude_sources=None)
|
||||
try:
|
||||
read_importable_agent_session_rows(db, exclude_sources=None)
|
||||
raise AssertionError("read-only open failure unexpectedly recovered")
|
||||
except sqlite3.OperationalError as exc:
|
||||
assert "synthetic read-only URI failure" in str(exc)
|
||||
|
||||
assert "cli-1" in {r["id"] for r in out}
|
||||
assert "read-only open failed" in caplog.text
|
||||
assert "synthetic read-only URI failure" in caplog.text
|
||||
# Exactly one attempt, and it was the read-only URI form.
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["uri"] is True
|
||||
assert "mode=ro" in calls[0]["target"]
|
||||
|
||||
|
||||
def test_index_present_performs_no_writable_connection(tmp_path, monkeypatch):
|
||||
@@ -124,7 +197,7 @@ def test_index_present_performs_no_writable_connection(tmp_path, monkeypatch):
|
||||
assert all(c["uri"] and "mode=ro" in c["target"] for c in calls), calls
|
||||
|
||||
|
||||
def test_missing_index_self_heals_via_separate_connection(tmp_path, monkeypatch):
|
||||
def test_missing_index_degrades_without_writable_connection(tmp_path, monkeypatch):
|
||||
db = tmp_path / "state.db"
|
||||
_make_db(db, with_index=False)
|
||||
calls = _record_connects(monkeypatch)
|
||||
@@ -133,12 +206,13 @@ def test_missing_index_self_heals_via_separate_connection(tmp_path, monkeypatch)
|
||||
|
||||
# Rows still come back...
|
||||
assert "cli-1" in {r["id"] for r in out}
|
||||
# ...and the self-heal ran through a separate writable (non-ro) connection.
|
||||
assert any((not c["uri"]) and "mode=ro" not in c["target"] for c in calls), calls
|
||||
# The index now exists on disk.
|
||||
# ...without any implicit schema-maintenance writer.
|
||||
assert calls
|
||||
assert all(c["uri"] and "mode=ro" in c["target"] for c in calls), calls
|
||||
# A missing index stays a maintenance concern, not a listing side effect.
|
||||
verify = sqlite3.connect(str(db))
|
||||
try:
|
||||
names = {row[1] for row in verify.execute("PRAGMA index_list(messages)")}
|
||||
finally:
|
||||
verify.close()
|
||||
assert "idx_messages_session" in names
|
||||
assert "idx_messages_session" not in names
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Regression tests for #6869: failed Gateway worker launch cleanup."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import api.config as config
|
||||
import api.routes as routes
|
||||
from api import turn_journal
|
||||
|
||||
|
||||
def _make_session(session_id, *, active_stream_id=None, pending_user_message=None, save=None):
|
||||
return SimpleNamespace(
|
||||
session_id=session_id,
|
||||
title="Existing",
|
||||
active_stream_id=active_stream_id,
|
||||
pending_user_message=pending_user_message,
|
||||
pending_attachments=[],
|
||||
pending_started_at=None,
|
||||
pending_user_source=None,
|
||||
messages=[{"role": "user", "content": "old"}],
|
||||
workspace="/tmp",
|
||||
model="old-model",
|
||||
model_provider=None,
|
||||
worktree_path=None,
|
||||
profile=None,
|
||||
save=save or (lambda: None),
|
||||
)
|
||||
|
||||
|
||||
def _register_failed_stream(session_id, stream_id):
|
||||
"""Mirror the registry state _prepare_chat_start_session_for_stream installs."""
|
||||
config.register_session_writeback_owner(session_id, stream_id)
|
||||
config.register_stream_owner(stream_id, session_id)
|
||||
config.STREAMS[stream_id] = object()
|
||||
|
||||
|
||||
def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state(monkeypatch):
|
||||
"""A thread-start exception must not leave one owner per failed Gateway launch."""
|
||||
class FailingThread:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
raise RuntimeError("thread launch failed")
|
||||
|
||||
canonical_by_id = {}
|
||||
registered_stream_ids = []
|
||||
|
||||
def fake_prepare(session, *, stream_id, **kwargs):
|
||||
session.active_stream_id = stream_id
|
||||
session.pending_user_message = kwargs["msg"]
|
||||
session.pending_started_at = 1.0
|
||||
_register_failed_stream(session.session_id, stream_id)
|
||||
canonical_by_id[session.session_id] = session
|
||||
registered_stream_ids.append(stream_id)
|
||||
|
||||
monkeypatch.setattr(routes.threading, "Thread", FailingThread)
|
||||
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare)
|
||||
monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace, **_kw: None)
|
||||
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_session",
|
||||
lambda sid, metadata_only=False: canonical_by_id[sid],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
turn_journal,
|
||||
"append_turn_journal_event",
|
||||
lambda *_args, **_kwargs: {},
|
||||
)
|
||||
|
||||
for index in range(3):
|
||||
session = _make_session(f"session-launch-failure-{index}")
|
||||
try:
|
||||
routes._start_chat_stream_for_session(
|
||||
session,
|
||||
msg="start gateway turn",
|
||||
attachments=[],
|
||||
workspace="/tmp",
|
||||
model="test-model",
|
||||
external_runtime_owned=True,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert str(exc) == "thread launch failed"
|
||||
else:
|
||||
raise AssertionError("thread-start failure must propagate")
|
||||
assert session.active_stream_id is None
|
||||
assert session.pending_user_message is None
|
||||
assert session.pending_started_at is None
|
||||
|
||||
# Scope these to the streams this test registered. scripts/test.sh runs the
|
||||
# whole selection in one process, so asserting the global registries are
|
||||
# empty makes this test fail on unrelated files that leave entries behind.
|
||||
assert registered_stream_ids, "the launch path must register state before failing"
|
||||
for stream_id in registered_stream_ids:
|
||||
assert stream_id not in config.STREAM_SESSION_OWNERS
|
||||
assert stream_id not in config.STREAMS
|
||||
for session_id in canonical_by_id:
|
||||
assert config.session_writeback_owner(session_id) is None
|
||||
|
||||
|
||||
def test_gateway_launch_failure_cleanup_does_not_clear_successor_owner(monkeypatch):
|
||||
"""A successor fully installed before cleanup keeps its registry ownership."""
|
||||
canonical = _make_session(
|
||||
"session-launch-successor",
|
||||
active_stream_id="new-stream",
|
||||
pending_user_message="new prompt",
|
||||
save=lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_session",
|
||||
lambda sid, metadata_only=False: canonical,
|
||||
)
|
||||
stale = _make_session("session-launch-successor")
|
||||
_register_failed_stream(stale.session_id, "old-stream")
|
||||
config.register_session_writeback_owner(stale.session_id, "new-stream")
|
||||
|
||||
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
|
||||
|
||||
assert config.session_writeback_owner(stale.session_id) == "new-stream"
|
||||
assert canonical.active_stream_id == "new-stream"
|
||||
assert canonical.pending_user_message == "new prompt"
|
||||
assert "old-stream" not in config.STREAMS
|
||||
|
||||
|
||||
def test_gateway_launch_failure_cleanup_does_not_wipe_successor_admitted_during_cleanup(monkeypatch):
|
||||
"""A successor admitted mid-cleanup survives: the guard runs on the re-resolved
|
||||
canonical session, not on the stale passed-in object that still shows the
|
||||
failed stream."""
|
||||
saved = []
|
||||
|
||||
canonical = _make_session(
|
||||
"session-launch-race",
|
||||
active_stream_id="new-stream",
|
||||
pending_user_message="successor prompt",
|
||||
save=lambda: saved.append("canonical"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_session",
|
||||
lambda sid, metadata_only=False: canonical,
|
||||
)
|
||||
stale = _make_session(
|
||||
"session-launch-race",
|
||||
active_stream_id="old-stream",
|
||||
pending_user_message="failed-stream prompt",
|
||||
save=lambda: saved.append("stale"),
|
||||
)
|
||||
_register_failed_stream(stale.session_id, "old-stream")
|
||||
config.register_session_writeback_owner(stale.session_id, "new-stream")
|
||||
|
||||
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
|
||||
|
||||
assert config.session_writeback_owner(stale.session_id) == "new-stream"
|
||||
assert canonical.active_stream_id == "new-stream"
|
||||
assert canonical.pending_user_message == "successor prompt"
|
||||
assert "old-stream" not in config.STREAMS
|
||||
assert saved == [], "cleanup must not save a session it early-returns on"
|
||||
|
||||
|
||||
def test_gateway_launch_failure_cleanup_does_not_resurrect_deleted_session(monkeypatch):
|
||||
"""A session deleted while the launch was failing must not be recreated:
|
||||
the resolver raises KeyError and cleanup returns without saving anything."""
|
||||
saved = []
|
||||
|
||||
def deleted_resolver(sid, metadata_only=False):
|
||||
raise KeyError(sid)
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", deleted_resolver)
|
||||
stale = _make_session(
|
||||
"session-launch-deleted",
|
||||
active_stream_id="old-stream",
|
||||
pending_user_message="pending prompt",
|
||||
save=lambda: saved.append("stale"),
|
||||
)
|
||||
_register_failed_stream(stale.session_id, "old-stream")
|
||||
|
||||
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
|
||||
|
||||
assert config.session_writeback_owner(stale.session_id) is None
|
||||
assert "old-stream" not in config.STREAM_SESSION_OWNERS
|
||||
assert "old-stream" not in config.STREAMS
|
||||
assert stale.active_stream_id == "old-stream"
|
||||
assert stale.pending_user_message == "pending prompt"
|
||||
assert saved == [], "cleanup must not save a deleted session"
|
||||
|
||||
|
||||
def test_gateway_launch_failure_cleanup_never_masks_the_launch_error(monkeypatch):
|
||||
"""A resolution failure during cleanup must not replace the launch error.
|
||||
|
||||
Cleanup runs inside the launch-failure handler, so an error it fails to
|
||||
contain is reported instead of the real launch failure. Resolution can fail
|
||||
with something other than KeyError when the session store is unreadable or
|
||||
cannot be deserialized.
|
||||
"""
|
||||
saved = []
|
||||
|
||||
def failing_resolver(sid, metadata_only=False):
|
||||
raise OSError("session store unreadable")
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", failing_resolver)
|
||||
stale = _make_session(
|
||||
"session-launch-resolution-error",
|
||||
active_stream_id="old-stream",
|
||||
pending_user_message="pending prompt",
|
||||
save=lambda: saved.append("stale"),
|
||||
)
|
||||
_register_failed_stream(stale.session_id, "old-stream")
|
||||
|
||||
# Must not raise: the caller is already handling the launch failure.
|
||||
routes._cleanup_chat_start_launch_failure(stale, "old-stream")
|
||||
|
||||
# The registry half still completed, and nothing was saved.
|
||||
assert config.session_writeback_owner(stale.session_id) is None
|
||||
assert "old-stream" not in config.STREAM_SESSION_OWNERS
|
||||
assert "old-stream" not in config.STREAMS
|
||||
assert saved == []
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Regression tests for issue #7056 — login page must hide the password form
|
||||
when password auth is disabled (e.g. native OIDC configured and
|
||||
``HERMES_WEBUI_PASSWORD`` unset).
|
||||
|
||||
The original bug: ``/api/auth/status`` correctly reports
|
||||
``password_auth_enabled: false`` when password auth is disabled, but
|
||||
``/login`` still rendered the password input, the "Sign in" button,
|
||||
and the "Continue with SSO" entry point — and silently 401-ed every
|
||||
submit. The fix routes the password input / submit / passkey controls
|
||||
through a single ``{{PASSWORD_FORM_HTML}}`` placeholder in the
|
||||
template, which the handler populates with the live markup only when
|
||||
``is_password_auth_enabled()`` is True.
|
||||
|
||||
The OIDC SSO button stays the sole path when password auth is disabled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import api.routes as routes
|
||||
from api.routes import _LOGIN_PAGE_HTML
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-shape: the template + handler must agree on a single placeholder
|
||||
# for the password controls, and the handler must call the live predicate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_template_uses_password_form_placeholder() -> None:
|
||||
"""The login template MUST carry a single ``{{PASSWORD_FORM_HTML}}`` placeholder.
|
||||
|
||||
A silent reversion to the previous multi-line ``<input>`` +
|
||||
``<button>`` + ``<button>`` shape fails the suite.
|
||||
"""
|
||||
assert "{{PASSWORD_FORM_HTML}}" in _LOGIN_PAGE_HTML, (
|
||||
"the login template must use a {{PASSWORD_FORM_HTML}} placeholder "
|
||||
"so the handler can route the password controls through the "
|
||||
"``is_password_auth_enabled()`` predicate"
|
||||
)
|
||||
|
||||
|
||||
def test_login_template_does_not_render_password_input_inline() -> None:
|
||||
"""The login template MUST NOT carry a literal ``<input type="password"``.
|
||||
|
||||
The fix replaced the inline input with the placeholder; a silent
|
||||
reversion that re-adds the inline input fails the suite.
|
||||
"""
|
||||
assert '<input type="password"' not in _LOGIN_PAGE_HTML, (
|
||||
"the login template must not carry an inline <input type=\"password\">; "
|
||||
"use the {{PASSWORD_FORM_HTML}} placeholder so the handler can "
|
||||
"gate the password form on is_password_auth_enabled()"
|
||||
)
|
||||
|
||||
|
||||
def test_login_handler_calls_is_password_auth_enabled() -> None:
|
||||
"""The /login branch in handle_get MUST call ``is_password_auth_enabled``.
|
||||
|
||||
Pins the contract that the live predicate — not a stale cached
|
||||
flag, not a config-only check — gates the password controls. A
|
||||
silent reversion to a hard-coded `True` (or any pre-baked
|
||||
assumption about the environment) fails the suite.
|
||||
"""
|
||||
src = (routes.__file__).read_text(encoding="utf-8") if hasattr(routes.__file__, "read_text") else open(routes.__file__).read()
|
||||
# Find the /login branch
|
||||
login_idx = src.index('if parsed.path == "/login":')
|
||||
# Find the next return statement that closes the /login branch
|
||||
end_idx = src.index("content_type=\"text/html; charset=utf-8\")", login_idx)
|
||||
branch = src[login_idx:end_idx]
|
||||
assert "is_password_auth_enabled" in branch, (
|
||||
"the /login branch must call is_password_auth_enabled() so the "
|
||||
"password form visibility tracks /api/auth/status at request time"
|
||||
)
|
||||
assert "PASSWORD_FORM_HTML" in branch, (
|
||||
"the /login branch must populate the {{PASSWORD_FORM_HTML}} "
|
||||
"placeholder with the live password form markup"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Behavioural: walk the /login handler with a real ``ParsedResult`` and
|
||||
# a minimal fake handler. We mock the side-effecting helpers (settings
|
||||
# + OIDC + auth predicate) so the test only covers the placeholder
|
||||
# logic, not the whole login stack.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self) -> None:
|
||||
self.status: int | None = None
|
||||
self.headers: dict[str, str] = {}
|
||||
self.wfile = io.BytesIO()
|
||||
self.command = "GET"
|
||||
self.path = "/login"
|
||||
self.client_address = ("127.0.0.1", 12345)
|
||||
self.request_version = "HTTP/1.1"
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.headers[key] = value
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _stub_oidc(enabled: bool):
|
||||
"""Mock the OIDC predicate the login branch uses to decide SSO button visibility.
|
||||
|
||||
``_oidc_login_html(parsed)`` is a module-level function the login
|
||||
branch calls; the mock has to accept a single positional ``parsed``
|
||||
argument and return a stable string. With OIDC enabled the
|
||||
fixture returns a marker substring (``oidc-login``) so the
|
||||
behavioural tests can assert its presence; with OIDC disabled it
|
||||
returns the empty string the real implementation returns.
|
||||
"""
|
||||
def _stub(parsed):
|
||||
del parsed
|
||||
if enabled:
|
||||
return (
|
||||
'<a id="oidc-login" class="oidc-login" '
|
||||
'href="/api/auth/oidc/start">Continue with SSO</a>'
|
||||
)
|
||||
return ""
|
||||
|
||||
return mock.patch.object(routes, "_oidc_login_html", _stub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_login(monkeypatch):
|
||||
"""Set up the login branch's external dependencies for behavioural tests.
|
||||
|
||||
Yields a (routes, parsed) tuple. Each test patches
|
||||
``is_password_auth_enabled`` and ``_oidc_login_html`` itself so the
|
||||
scenario is explicit.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
routes, "_LOGIN_LOCALE",
|
||||
{
|
||||
"en": {
|
||||
"lang": "en",
|
||||
"title": "Sign in",
|
||||
"subtitle": "Sign in to continue",
|
||||
"placeholder": "Password",
|
||||
"btn": "Sign in",
|
||||
"invalid_pw": "Invalid password",
|
||||
"conn_failed": "Connection failed",
|
||||
}
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(routes, "_resolve_login_locale_key", lambda _lang: "en")
|
||||
monkeypatch.setattr(routes, "load_settings", lambda: {"bot_name": "Hermes"})
|
||||
from api import updates as _updates
|
||||
monkeypatch.setattr(_updates, "WEBUI_VERSION", "test-0.0.0")
|
||||
import html as _html
|
||||
monkeypatch.setattr(routes, "_html", _html)
|
||||
from urllib.parse import urlparse
|
||||
yield routes, urlparse("/login?next=/x")
|
||||
|
||||
|
||||
def test_login_renders_password_form_when_password_auth_enabled(patched_login, monkeypatch) -> None:
|
||||
routes, parsed = patched_login
|
||||
monkeypatch.setattr(
|
||||
"api.auth.is_password_auth_enabled", lambda: True, raising=False
|
||||
)
|
||||
with _stub_oidc(enabled=False):
|
||||
handler = _FakeHandler()
|
||||
routes.handle_get(handler, parsed)
|
||||
html = handler.wfile.getvalue().decode("utf-8", "replace")
|
||||
|
||||
assert '<input type="password"' in html, html
|
||||
assert 'id="pw"' in html, html
|
||||
assert '<button type="submit"' in html, html
|
||||
assert 'id="passkey-login"' in html, html
|
||||
|
||||
|
||||
def test_login_hides_password_form_when_password_auth_disabled(patched_login, monkeypatch) -> None:
|
||||
"""With password auth disabled (e.g. OIDC-only), the form must NOT render
|
||||
the password input / submit / passkey controls. The OIDC SSO entry
|
||||
point (when OIDC is enabled) stays the sole path.
|
||||
"""
|
||||
routes, parsed = patched_login
|
||||
monkeypatch.setattr(
|
||||
"api.auth.is_password_auth_enabled", lambda: False, raising=False
|
||||
)
|
||||
with _stub_oidc(enabled=True):
|
||||
handler = _FakeHandler()
|
||||
routes.handle_get(handler, parsed)
|
||||
html = handler.wfile.getvalue().decode("utf-8", "replace")
|
||||
|
||||
# The password controls are gone.
|
||||
assert '<input type="password"' not in html, html
|
||||
assert 'id="pw"' not in html, html
|
||||
assert '<button type="submit"' not in html, html
|
||||
assert 'id="passkey-login"' not in html, html
|
||||
# The OIDC SSO entry point is still present (the user must still
|
||||
# be able to log in — just via SSO, not via password). We assert
|
||||
# the <a id="oidc-login"> element rather than the bare substring
|
||||
# because the template's CSS class ``.oidc-login`` would match
|
||||
# the page even when OIDC is disabled.
|
||||
assert 'id="oidc-login"' in html, html
|
||||
|
||||
|
||||
def test_login_hides_oidc_button_when_oidc_disabled(patched_login, monkeypatch) -> None:
|
||||
"""Sanity: with password auth disabled AND OIDC disabled, the page is
|
||||
effectively empty — no controls. This pre-existing behaviour is
|
||||
unchanged by #7056 (the fix only adds the password-disabled path).
|
||||
"""
|
||||
routes, parsed = patched_login
|
||||
monkeypatch.setattr(
|
||||
"api.auth.is_password_auth_enabled", lambda: False, raising=False
|
||||
)
|
||||
with _stub_oidc(enabled=False):
|
||||
handler = _FakeHandler()
|
||||
routes.handle_get(handler, parsed)
|
||||
html = handler.wfile.getvalue().decode("utf-8", "replace")
|
||||
|
||||
assert '<input type="password"' not in html, html
|
||||
assert 'id="oidc-login"' not in html, html
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Regression tests for issue #7290 — configured-model badge must not leak a
|
||||
``{provider}/{model}``-prefixed alias when the native model id itself
|
||||
contains a slash.
|
||||
|
||||
When the active provider is a built-in plugin provider (e.g. ``commandcode``)
|
||||
and the configured default model id contains a slash (e.g.
|
||||
``deepseek/deepseek-v4-flash``), the configured-model badge builder used
|
||||
to emit three key variants for the same configured identity:
|
||||
|
||||
- the bare native id (``deepseek/deepseek-v4-flash``)
|
||||
- a slash-prefixed alias (``commandcode/deepseek/deepseek-v4-flash``)
|
||||
- the ``@provider:model``-prefixed form (``@commandcode:deepseek/deepseek-v4-flash``)
|
||||
|
||||
The slash-prefixed alias was a synthetic key the catalog never actually
|
||||
exposed — the WebUI dropdown rendered it as a real option, selecting it
|
||||
sent ``model=commandcode/deepseek/deepseek-v4-flash`` to the agent, and
|
||||
the provider rejected it with ``HTTP 400: Model ... is not supported on
|
||||
this endpoint``. The session also persisted the wrong id, so reload
|
||||
replayed the failure.
|
||||
|
||||
The fix removes the ``{provider}/{model}`` synthetic alias. Bare native
|
||||
id (which is what the catalog actually exposes) and ``@provider:model``
|
||||
(which is the documented prefix form for named custom providers) remain.
|
||||
Multi-slash, URI-scheme, host:port, and ``@provider:model``-prefixed
|
||||
model ids all keep working — only the synthesised slash-prefixed alias
|
||||
disappears.
|
||||
|
||||
The pre-fix test below pins both failure modes reported in the issue:
|
||||
|
||||
1. the badge map MUST NOT contain the slash-prefixed alias
|
||||
2. selecting the bare native id from the dropdown MUST NOT produce a
|
||||
request whose ``model`` field is the slash-prefixed alias
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _build_badges(active_provider, default_model, groups, fallback_providers=None):
|
||||
"""Inline-exec the live ``_build_configured_model_badges`` and return its result.
|
||||
|
||||
``_norm_model_id`` is inlined as a stand-in (its full body is part of
|
||||
the production source and is exercised by ``test_norm_model_id_*``
|
||||
siblings). Mirrors the existing pattern in
|
||||
``test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only``.
|
||||
"""
|
||||
src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
start = src.index("def _build_configured_model_badges() -> dict[str, dict[str, str]]:")
|
||||
end = src.index(" return badges", start) + len(" return badges")
|
||||
fn_src = textwrap.dedent(src[start:end])
|
||||
|
||||
scope = {
|
||||
"active_provider": active_provider,
|
||||
"default_model": default_model,
|
||||
"cfg": {"fallback_providers": fallback_providers or []},
|
||||
"groups": groups,
|
||||
"_resolve_provider_alias": lambda provider: provider,
|
||||
}
|
||||
# The live source uses a different `_norm_model_id` than the test's
|
||||
# inline; either is fine because the badge key is the **raw** candidate,
|
||||
# not the normalized key, so the raw_candidates list is the surface this
|
||||
# test pins.
|
||||
exec(
|
||||
"def _norm_model_id(model_id):\n"
|
||||
" s=str(model_id or '').strip().lower()\n"
|
||||
" if s.startswith('@') and ':' in s: s=s.split(':',1)[1]\n"
|
||||
" if '/' in s: s=s.split('/',1)[1]\n"
|
||||
" return s.replace('-', '.')\n",
|
||||
scope,
|
||||
)
|
||||
exec(fn_src, scope)
|
||||
return scope["_build_configured_model_badges"]()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repro: commandcode + deepseek/deepseek-v4-flash — the literal bug report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_commandcode_deepseek_does_not_emit_slash_prefixed_alias() -> None:
|
||||
"""The badge map MUST NOT contain ``commandcode/deepseek/deepseek-v4-flash``."""
|
||||
badges = _build_badges(
|
||||
active_provider="commandcode",
|
||||
default_model="deepseek/deepseek-v4-flash",
|
||||
groups=[
|
||||
# The catalog exposes the bare native id under the commandcode
|
||||
# provider — that's the only selectable form.
|
||||
{
|
||||
"provider": "CommandCode",
|
||||
"provider_id": "commandcode",
|
||||
"models": [{"id": "deepseek/deepseek-v4-flash"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
# The slash-prefixed alias MUST NOT appear in the badge map.
|
||||
assert "commandcode/deepseek/deepseek-v4-flash" not in badges, (
|
||||
f"slash-prefixed alias leaked into the badge map: {badges!r}"
|
||||
)
|
||||
# The bare native id SHOULD be the primary badge (it is the only
|
||||
# catalog-exposed form and is the correct value to send to the agent).
|
||||
assert badges.get("deepseek/deepseek-v4-flash", {}).get("role") == "primary"
|
||||
|
||||
|
||||
def test_commandcode_deepseek_at_prefixed_alias_still_present() -> None:
|
||||
"""The ``@provider:model`` prefix form MUST still resolve to the primary badge."""
|
||||
badges = _build_badges(
|
||||
active_provider="commandcode",
|
||||
default_model="deepseek/deepseek-v4-flash",
|
||||
groups=[
|
||||
{
|
||||
"provider": "CommandCode",
|
||||
"provider_id": "commandcode",
|
||||
"models": [{"id": "deepseek/deepseek-v4-flash"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert badges.get("@commandcode:deepseek/deepseek-v4-flash", {}).get("role") == "primary", (
|
||||
f"@commandcode:deepseek/deepseek-v4-flash alias missing from badges: {badges!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dedupe across providers: a slash-bearing id shared by two providers must
|
||||
# keep the badge only on the matching provider's row, never the other way.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_duplicate_slash_id_does_not_pollute_other_provider() -> None:
|
||||
"""Per #7290 contract: deduplicate only when provider+full native model match."""
|
||||
badges = _build_badges(
|
||||
active_provider="custom:alpha",
|
||||
default_model="vendor/abc-model",
|
||||
groups=[
|
||||
{
|
||||
"provider": "Alpha",
|
||||
"provider_id": "custom:alpha",
|
||||
"models": [{"id": "vendor/abc-model"}],
|
||||
},
|
||||
{
|
||||
"provider": "Beta",
|
||||
"provider_id": "custom:beta",
|
||||
"models": [{"id": "vendor/abc-model"}],
|
||||
},
|
||||
],
|
||||
)
|
||||
# The bare native id is a real option for BOTH providers; only the
|
||||
# matching provider's row gets the PRIMARY badge.
|
||||
payload = badges.get("vendor/abc-model", {})
|
||||
assert payload.get("role") == "primary"
|
||||
assert payload.get("provider") == "custom:alpha"
|
||||
# The OTHER provider's @-prefixed form is not produced (entry is for alpha
|
||||
# only); but the alpha @-prefixed form IS produced.
|
||||
assert "vendor/abc-model" in badges
|
||||
# No synthetic slash-prefixed alias for the other provider.
|
||||
assert "custom:beta/vendor/abc-model" not in badges
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity: other forms of model id (URI scheme, host:port, @provider:model)
|
||||
# must keep working — only the synthetic slash-prefixed alias is removed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
"deepseek-v4-pro", # bare id (no slash) — the common case
|
||||
"@anthropic:claude-opus-4-7", # @-prefixed named custom provider
|
||||
"gpt://folder/abc/latest", # URI-scheme id (#3429)
|
||||
"https://proxy.internal/models/gpt4", # URL id (#3429)
|
||||
],
|
||||
)
|
||||
def test_non_slash_models_unchanged(model_id: str) -> None:
|
||||
"""No regression: the bare / @-prefixed / URI-scheme id forms are unchanged."""
|
||||
badges = _build_badges(
|
||||
active_provider="commandcode",
|
||||
default_model=model_id,
|
||||
groups=[
|
||||
{
|
||||
"provider": "CommandCode",
|
||||
"provider_id": "commandcode",
|
||||
"models": [{"id": model_id}],
|
||||
}
|
||||
],
|
||||
)
|
||||
# The bare native id (or its @-prefixed alias) is the primary badge.
|
||||
assert badges.get(model_id, {}).get("role") == "primary", (
|
||||
f"bare id missing primary for {model_id!r}: {badges!r}"
|
||||
)
|
||||
# No synthesised slash-prefixed alias for ANY form.
|
||||
slash_aliases = [k for k in badges if k.startswith("commandcode/") and k != model_id]
|
||||
assert not slash_aliases, (
|
||||
f"synthesised slash-prefixed aliases leaked: {slash_aliases!r} in {badges!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-shape: the ``{provider}/{model}`` synthetic alias must not appear
|
||||
# in either configured-badge builder. A silent reversion to the bug
|
||||
# leaves this regex matchable and the test fails.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"needle",
|
||||
[
|
||||
# _build_configured_model_badges (line ~8618) — 12-space indent
|
||||
'f"{provider}/{model}"',
|
||||
# _configured_model_badges_from_static_catalog (line ~6943) — 8-space
|
||||
'f"{provider}/{model}"',
|
||||
],
|
||||
)
|
||||
def test_synthetic_slash_prefix_alias_is_not_in_source(needle: str) -> None:
|
||||
"""The synthetic ``{provider}/{model}`` alias MUST NOT appear in either builder."""
|
||||
src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
assert needle not in src, (
|
||||
f"the synthetic slash-prefixed alias leaked back into the source: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_raw_candidates_only_holds_bare_and_at_prefix() -> None:
|
||||
"""The raw_candidates tuple MUST contain only the bare native id and the ``@provider:`` alias.
|
||||
|
||||
Catches both the old bug and a future regression that adds back
|
||||
*any* synthetic prefix form (e.g. ``{provider}::{model}``).
|
||||
"""
|
||||
src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
# Find every `for candidate in (` block and verify the contents.
|
||||
# The 12-space variant is the main builder; the 8-space variant is the
|
||||
# static-catalog fallback; both must contain only bare + @-prefix.
|
||||
pattern = re.compile(
|
||||
r"for candidate in \(([\s\S]*?)\):\s*\n\s+if candidate and candidate not in raw_candidates:",
|
||||
)
|
||||
matches = list(pattern.finditer(src))
|
||||
assert matches, "raw_candidates tuple not found in api/config.py"
|
||||
for m in matches:
|
||||
body = m.group(1)
|
||||
# The body may be a single line `model, f"@{provider}:{model}"` or
|
||||
# a multi-line stack; both are accepted. We only need to verify the
|
||||
# two safe entries are present and the synthesised one is gone.
|
||||
bare = re.search(r"\bmodel\b", body)
|
||||
at_prefixed = re.search(
|
||||
r'f["\']@\{provider\}:\{model\}["\']', body
|
||||
)
|
||||
assert bare, f"raw_candidates missing the bare `model` entry: {body!r}"
|
||||
assert at_prefixed, (
|
||||
f"raw_candidates missing the @-prefixed entry, or contains a "
|
||||
f"synthesised prefix form: {body!r}"
|
||||
)
|
||||
# No synthesised prefix form like `{provider}/{model}` survives.
|
||||
assert "{provider}/{model}" not in body, (
|
||||
f"raw_candidates still emits a synthesised slash-prefixed alias: {body!r}"
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Regression tests for issue #7299 — hidden-tab active-stream poll
|
||||
keeps retrying deleted sessions forever, generating an invisible stream
|
||||
of permanent 404 requests from stale background tabs.
|
||||
|
||||
When a tab is hidden, Hermes WebUI replaces the persistent per-session
|
||||
SSE connection with a lightweight ``GET /api/session/status`` poll
|
||||
every six seconds. If the tracked session has been deleted or no
|
||||
longer exists in the active state directory, the endpoint returns
|
||||
``404``, but the client previously converted every non-2xx response to
|
||||
``null`` and kept the interval alive forever — one tab generates up to
|
||||
ten requests per minute whenever browser timer throttling allows it.
|
||||
|
||||
The fix: in ``_startHiddenActiveStreamPoll``'s fetch chain, treat
|
||||
``404 Not Found`` and ``410 Gone`` as **terminal** for the
|
||||
session-owned poll — call ``_stopHiddenActiveStreamPoll()`` so the
|
||||
interval and the bound ``_sessionStreamHiddenPollSid`` are cleared
|
||||
together. Transient failures (5xx, rate-limit, network error) keep
|
||||
polling because they do not necessarily mean the session is gone; a
|
||||
later tick may catch a server-initiated turn starting while the tab
|
||||
remains hidden.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-shape: the fetch chain in ``_startHiddenActiveStreamPoll`` must
|
||||
# gate the terminal 404/410 path through ``_stopHiddenActiveStreamPoll``
|
||||
# and the bound sid check. A silent reversion to the pre-fix
|
||||
# "convert all non-2xx to null and keep polling" form fails the suite.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hidden_poll_stops_on_404_response() -> None:
|
||||
"""The fetch chain must call ``_stopHiddenActiveStreamPoll`` on a 404.
|
||||
|
||||
A 404 from ``/api/session/status`` means the session has been
|
||||
deleted or no longer exists in the active state directory. The
|
||||
pre-fix code converted every non-2xx to ``null`` and never
|
||||
stopped the interval — so the same 404 fired every six seconds
|
||||
for the lifetime of the hidden tab. Pin the contract that a 404
|
||||
now tears down the poll.
|
||||
"""
|
||||
# The check is anchored on the hidden-poll function so a future
|
||||
# refactor cannot satisfy it with an unrelated status-404
|
||||
# handler in a different code path.
|
||||
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||
assert start != -1
|
||||
# 3.6 KB is enough to cover the fetch → first .then → second .then
|
||||
# block, even after the #7299 fix added the 404/410 terminal
|
||||
# branch. The hidden poll is one of the larger functions in the
|
||||
# file, so we take a wide window to be resilient to a small
|
||||
# reformat.
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
assert "r.status === 404" in body, (
|
||||
"the hidden poll must check r.status === 404 in the fetch "
|
||||
"response so a deleted session no longer triggers an "
|
||||
"infinite 404 loop on a stale background tab"
|
||||
)
|
||||
assert "_stopHiddenActiveStreamPoll" in body, (
|
||||
"the hidden poll must call _stopHiddenActiveStreamPoll when "
|
||||
"the response is terminal (404/410) so the interval is "
|
||||
"cleared instead of left running"
|
||||
)
|
||||
|
||||
|
||||
def test_hidden_poll_stops_on_410_response() -> None:
|
||||
"""The fetch chain must also call ``_stopHiddenActiveStreamPoll`` on 410.
|
||||
|
||||
Some session-state endpoints use ``410 Gone`` (rather than
|
||||
``404 Not Found``) for "this session id is permanently gone".
|
||||
Both are terminal for the hidden poll — the pre-fix code treated
|
||||
them identically, so the fix must handle both.
|
||||
"""
|
||||
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
assert "r.status === 410" in body, (
|
||||
"the hidden poll must check r.status === 410 in the fetch "
|
||||
"response so a 'gone' session also tears down the poll, "
|
||||
"matching the 404 contract"
|
||||
)
|
||||
|
||||
|
||||
def test_hidden_poll_404_stop_guards_on_bound_sid() -> None:
|
||||
"""The 404/410 stop path must check the bound ``_sessionStreamHiddenPollSid``.
|
||||
|
||||
The same fetch chain is invoked on every tick of a 6-second
|
||||
interval. A race can let a stale fetch's response land after
|
||||
the user switched sessions — in that case ``_sessionStreamHiddenPollSid``
|
||||
no longer matches the fetched ``sid``, and the stop must NOT
|
||||
tear down the *new* session's poll. Pin the contract.
|
||||
"""
|
||||
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
# The 404/410 branch must check the bound sid before stopping
|
||||
# — pinned as a string match because the JS module is large
|
||||
# and we want a regression to show up at a glance in CI.
|
||||
assert "_sessionStreamHiddenPollSid === sid" in body, (
|
||||
"the 404/410 stop path must guard on "
|
||||
"_sessionStreamHiddenPollSid === sid so a stale fetch's "
|
||||
"response cannot tear down a different session's poll"
|
||||
)
|
||||
|
||||
|
||||
def test_hidden_poll_preserves_transient_failure_retry() -> None:
|
||||
"""5xx / rate-limit / network errors MUST keep polling (not stop).
|
||||
|
||||
The fix only narrows the stop path to terminal 404/410. Transient
|
||||
failures (5xx, 429, network error) still produce ``null`` and
|
||||
the next tick re-fires. A regression that broadens the stop
|
||||
path to all non-2xx would re-introduce the original bug in
|
||||
the opposite direction — the poll would die on a single
|
||||
503 and never recover.
|
||||
"""
|
||||
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
# The pre-fix ``r.ok ? r.json() : null`` fallback must still be
|
||||
# present for any non-ok / non-terminal response.
|
||||
assert "r.ok ? r.json() : null" in body, (
|
||||
"the 5xx / rate-limit / network-error path must still "
|
||||
"produce null and let the next tick re-fire; only 404/410 "
|
||||
"should tear down the poll"
|
||||
)
|
||||
|
||||
|
||||
def test_hidden_poll_starts_interval_unchanged() -> None:
|
||||
"""The interval is unchanged (still 6000 ms).
|
||||
|
||||
The fix is purely about the response handling, not the
|
||||
cadence. A regression that doubles the interval (or removes
|
||||
the immediate-fire) breaks the original contract — a turn
|
||||
already running when the tab goes hidden must be caught
|
||||
without waiting a full interval.
|
||||
"""
|
||||
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
|
||||
body = MESSAGES_JS[start:start + 8000]
|
||||
assert "setInterval(tick, 6000)" in body, (
|
||||
"the hidden poll must keep the 6000ms interval; the fix is "
|
||||
"in the response handling, not the cadence"
|
||||
)
|
||||
# The immediate-fire call (no full interval wait) must also
|
||||
# still be present so a turn already running when the tab goes
|
||||
# hidden is caught without waiting a full interval.
|
||||
assert "tick();" in body, (
|
||||
"the immediate-fire tick() call must be preserved so a "
|
||||
"turn already running when the tab goes hidden is caught "
|
||||
"without waiting a full interval"
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""#7305: GET /api/profiles must never 500 when the hermes-agent source is not
|
||||
importable (two-container Docker / HERMES_WEBUI_CHAT_BACKEND=gateway).
|
||||
|
||||
Contract pinned here: when ``agent.skill_utils`` is missing, the WebUI reports
|
||||
the skill stats as *unknown* — a stable ``(0, 0)`` — instead of counting skills
|
||||
with a local partial re-implementation of the index walk. The profile picker
|
||||
still renders, and it never claims a count it cannot verify (the UI omits the
|
||||
skills line when the total is 0).
|
||||
|
||||
The fixture tree deliberately reproduces the shapes that a partial local scan
|
||||
gets wrong: a SKILL.md whose declared ``name`` differs from its directory, two
|
||||
directories declaring the same name, a declared name disabled in the profile
|
||||
config, a platform-incompatible skill, and the paths the real index walk prunes
|
||||
(support dirs of a skill package, dependency/VCS/metadata dirs, an inactive
|
||||
org-mirror). ``test_agent_backed_path_counts_these_shapes_accurately`` is the
|
||||
positive control: it runs the same tree through the real agent-backed path, so
|
||||
the fixture cannot silently degrade into "a tree that never had a count to
|
||||
lose".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import api.profiles as profiles
|
||||
|
||||
# How a fixture entry relates to the reported counts.
|
||||
COUNTED = "counted" # yields a logical skill; contributes to compatible/enabled
|
||||
WALK_PRUNED = "walk-pruned" # never yielded by agent.skill_utils' index walk
|
||||
PLATFORM_FILTERED = "platform-filtered" # walked, but incompatible with this host
|
||||
|
||||
# path -> (declared frontmatter name, platform restriction or None, outcome)
|
||||
SKILLS_TREE = {
|
||||
# Declared name differs from the directory name; that declared name is
|
||||
# disabled for the webui platform in config.yaml below.
|
||||
"alpha-dir/SKILL.md": ("my-skill", None, COUNTED),
|
||||
# A second directory declaring the same name — one logical skill.
|
||||
"beta-dir/SKILL.md": ("my-skill", None, COUNTED),
|
||||
# Platform-incompatible: must not be offered (or counted) on this host.
|
||||
"gamma-dir/SKILL.md": ("gamma-skill", "some-other-platform", PLATFORM_FILTERED),
|
||||
# A plain, enabled skill.
|
||||
"delta-dir/SKILL.md": ("delta-skill", None, COUNTED),
|
||||
# Support dir of a skill package: disclosure-only, not a standalone skill.
|
||||
"delta-dir/references/notes/SKILL.md": ("support-note", None, WALK_PRUNED),
|
||||
# Dependency / virtualenv / VCS / metadata dirs are excluded wholesale, and
|
||||
# an org mirror without its .active_org marker is token-gated shut.
|
||||
"node_modules/dep/SKILL.md": ("vendored-skill", None, WALK_PRUNED),
|
||||
".venv/lib/pkg/SKILL.md": ("venv-skill", None, WALK_PRUNED),
|
||||
".git/hooks/SKILL.md": ("hook-skill", None, WALK_PRUNED),
|
||||
"_org/other-org/org-skill/SKILL.md": ("org-skill", None, WALK_PRUNED),
|
||||
}
|
||||
|
||||
# The one declared name the profile config disables for the webui platform.
|
||||
DISABLED_NAMES = {"my-skill"}
|
||||
|
||||
CONFIG_YAML = (
|
||||
"profile:\n"
|
||||
" name: default\n"
|
||||
"skills:\n"
|
||||
" platform_disabled:\n"
|
||||
" webui:\n"
|
||||
" - my-skill\n"
|
||||
)
|
||||
|
||||
CONFIG_WITHOUT_DISABLE = "profile:\n name: default\n"
|
||||
|
||||
|
||||
def _expected_walked() -> set[str]:
|
||||
return {
|
||||
rel
|
||||
for rel, (_, _, outcome) in SKILLS_TREE.items()
|
||||
if outcome != WALK_PRUNED
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_absent(monkeypatch):
|
||||
"""Simulate the agent-less deployment: importing anything under ``agent.``
|
||||
raises ``ImportError``, even when the source tree is present locally."""
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _no_agent(name, *args, **kwargs):
|
||||
if name == "agent" or str(name).startswith("agent."):
|
||||
raise ImportError(f"{name} not mounted (simulated two-container Docker)")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _no_agent)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_cli_absent(monkeypatch):
|
||||
"""Simulate the same deployment's other half: no ``hermes_cli`` either."""
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _no_cli(name, *args, **kwargs):
|
||||
if name == "hermes_cli" or str(name).startswith("hermes_cli."):
|
||||
raise ImportError(f"{name} not mounted (simulated two-container Docker)")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _no_cli)
|
||||
|
||||
|
||||
def _write_profile(tmp_path: pathlib.Path, dir_name: str = "home") -> pathlib.Path:
|
||||
"""Materialise SKILLS_TREE + config.yaml as a profile home."""
|
||||
home = tmp_path / dir_name
|
||||
skills = home / "skills"
|
||||
for rel, (name, platform, _outcome) in SKILLS_TREE.items():
|
||||
skill_md = skills / rel
|
||||
skill_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = ["---", f"name: {name}", "description: issue 7305 fixture"]
|
||||
if platform:
|
||||
lines += ["platforms:", f" - {platform}"]
|
||||
lines += ["---", "body", ""]
|
||||
skill_md.write_text("\n".join(lines), encoding="utf-8")
|
||||
(home / "config.yaml").write_text(CONFIG_YAML, encoding="utf-8")
|
||||
return home
|
||||
|
||||
|
||||
def test_skills_stats_are_unknown_without_agent_skill_utils(tmp_path, agent_absent):
|
||||
"""Without agent.skill_utils the stats are (0, 0) — never a guessed count,
|
||||
no matter which fixtures a partial local scan would have counted."""
|
||||
home = _write_profile(tmp_path)
|
||||
|
||||
assert profiles._get_profile_skills_stats(home) == (0, 0)
|
||||
|
||||
|
||||
def test_default_profile_dict_never_raises_without_agent(tmp_path, agent_absent, monkeypatch):
|
||||
"""_default_profile_dict() (the fallback row for GET /api/profiles) must
|
||||
return a usable row with unknown skill stats instead of raising."""
|
||||
home = _write_profile(tmp_path)
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home)
|
||||
|
||||
row = profiles._default_profile_dict()
|
||||
|
||||
assert row["name"] == "default"
|
||||
assert row["path"] == str(home)
|
||||
assert row["is_default"] is True
|
||||
assert row["skill_count"] == 0
|
||||
assert row["enabled_skills"] == 0
|
||||
assert row["total_skills"] == 0
|
||||
|
||||
|
||||
def test_list_profiles_api_isolated_mode_without_agent_or_cli(
|
||||
tmp_path, agent_absent, hermes_cli_absent, monkeypatch
|
||||
):
|
||||
"""The #7305 path end to end: isolated profile mode with neither
|
||||
agent.skill_utils nor hermes_cli importable must still return the
|
||||
default-only row instead of propagating ImportError out of the route."""
|
||||
home = _write_profile(tmp_path, dir_name="default")
|
||||
monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: True)
|
||||
monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(home))
|
||||
|
||||
rows = profiles.list_profiles_api()
|
||||
|
||||
assert [r["name"] for r in rows] == ["default"]
|
||||
assert rows[0]["is_active"] is True
|
||||
assert rows[0]["skill_count"] == 0
|
||||
assert rows[0]["enabled_skills"] == 0
|
||||
assert rows[0]["total_skills"] == 0
|
||||
|
||||
|
||||
def test_agent_backed_path_counts_these_shapes_accurately(tmp_path):
|
||||
"""Positive control (agent source present): the fixture above is a real
|
||||
semantic trap, not a tree with nothing to count.
|
||||
|
||||
The fixture's expectations are derived from the paths the agent's index
|
||||
walk actually yields, because that walk (and its exclusion list) belongs to
|
||||
the agent package and differs between deployments. What this asserts:
|
||||
|
||||
- the compatible total stays strictly below the walked file count — the
|
||||
support/dependency/VCS/org-mirror files a naive local ``os.walk`` picks
|
||||
up are either pruned by the walk or, when the walk keeps them, still not
|
||||
enough to reach the walk's file count;
|
||||
- the platform-incompatible and duplicate-declared-name files do not add to
|
||||
the compatible total;
|
||||
- identity comes from frontmatter ``name``: the disabled ``my-skill`` (a
|
||||
declared name, not a directory) is compatible but not enabled;
|
||||
- dropping that config disable flips it to enabled without changing the
|
||||
compatible total.
|
||||
"""
|
||||
skill_utils = pytest.importorskip("agent.skill_utils")
|
||||
# Other test files install a bare ``types.ModuleType`` stub for
|
||||
# ``agent.skill_utils`` in ``sys.modules`` (no ``__file__``, rglob walk,
|
||||
# MagicMock frontmatter). This positive control is only meaningful against
|
||||
# the real agent package, so a stub must skip it, not satisfy it.
|
||||
if not getattr(skill_utils, "__file__", None):
|
||||
pytest.skip("agent.skill_utils is a test stub, not the real agent package")
|
||||
from agent.skill_utils import iter_skill_index_files
|
||||
|
||||
home = _write_profile(tmp_path)
|
||||
skills = home / "skills"
|
||||
|
||||
walked = {
|
||||
p.relative_to(skills).as_posix() for p in iter_skill_index_files(skills, "SKILL.md")
|
||||
}
|
||||
on_disk = {p.relative_to(skills).as_posix() for p in skills.rglob("SKILL.md")}
|
||||
|
||||
# The index walk itself belongs to the agent package and its exclusion list
|
||||
# is version-dependent, so this test does not freeze it: every expectation
|
||||
# below is derived from the paths the walk actually yielded. (Removing the
|
||||
# local mirror of that walk is the point of #7305 — the fallback reported
|
||||
# (0, 0) instead of guessing.)
|
||||
assert _expected_walked() <= walked, "the walk must keep the non-pruned skills"
|
||||
assert walked <= on_disk
|
||||
|
||||
expected_compatible = {
|
||||
name
|
||||
for rel, (name, platform, _outcome) in SKILLS_TREE.items()
|
||||
if rel in walked and platform is None
|
||||
}
|
||||
expected_enabled = expected_compatible - DISABLED_NAMES
|
||||
|
||||
# The fixture is a real semantic trap: the compatible total must stay below
|
||||
# the walked file count. If the walk pruned the support/dependency/VCS/org
|
||||
# files the count drops because those files are gone; if it did not, the
|
||||
# count still drops because the platform-incompatible file is filtered out
|
||||
# and the two directories declaring `my-skill` collapse to one logical
|
||||
# skill.
|
||||
assert len(expected_compatible) < len(walked), (
|
||||
"fixture must make the compatible total fall below the walked file count"
|
||||
)
|
||||
|
||||
enabled, compatible = profiles._compute_profile_skills_stats(home)
|
||||
|
||||
assert compatible == len(expected_compatible), (compatible, expected_compatible)
|
||||
assert compatible < len(on_disk)
|
||||
assert enabled == len(expected_enabled)
|
||||
|
||||
(home / "config.yaml").write_text(CONFIG_WITHOUT_DISABLE, encoding="utf-8")
|
||||
enabled_all, compatible_all = profiles._compute_profile_skills_stats(home)
|
||||
|
||||
assert compatible_all == compatible
|
||||
assert enabled_all == len(expected_compatible)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Regression tests for issue #7359 — MEDIA: token regex must not capture trailing backtick.
|
||||
|
||||
Two-pass scan (#7680 re-gate, 9/22):
|
||||
|
||||
- `` `MEDIA:path` `` (backtick-wrapped, inline-code form): the wrapping
|
||||
backticks are stripped by a pre-pass before the bare class scans the
|
||||
text. This makes the closing backtick disappear as a wrapper instead
|
||||
of being captured as part of the path.
|
||||
- ``MEDIA:/path...`` (bare, no surrounding backticks): the capture class
|
||||
no longer lists the backtick in the exclusion set, so a filename
|
||||
that legally contains a backtick (``report`final.png``) is captured
|
||||
in full instead of being truncated at the first backtick.
|
||||
|
||||
The pre-fix (round-1) PR added the backtick to the bare exclusion set,
|
||||
which traded one bug for another: it dropped the closing wrapper on
|
||||
the inline-code form but truncated any bare path that happened to
|
||||
contain a backtick in the filename. The two-pass scan keeps the
|
||||
original permissive bare class and adds an explicit pre-pass for the
|
||||
wrapped form, so both cases round-trip correctly.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Source-shape: each site's expected bare-token capture literal. The
|
||||
# bare class no longer lists the backtick in the exclusion set; a
|
||||
# backtick inside the filename (legal on POSIX and Windows) is
|
||||
# captured in full. The wrapped-form pre-pass below strips the
|
||||
# inline-code backticks before the bare scan.
|
||||
JS_SITES: dict[str, list[str]] = {
|
||||
"static/ui.js": [
|
||||
r"/MEDIA:([^\s\)\]]+)/g", # linkifier
|
||||
r"/MEDIA:[^\s]+/g", # TTS flattener (no paren/bracket exclusion)
|
||||
],
|
||||
"static/messages.js": [
|
||||
r"/^MEDIA:([^\s\)\]]+)$/", # streaming chunk finalize
|
||||
r"/MEDIA:([^\s\)\]]+)/g", # streaming combined walk
|
||||
r"/MEDIA:[^\s\)\]]*$/", # streaming partial tail
|
||||
],
|
||||
}
|
||||
PY_SITES: dict[str, str] = {
|
||||
"api/routes.py": r"MEDIA:([^\s\)\]]+)",
|
||||
"api/media_snapshots.py": r"MEDIA:([^\s\)\]]+)",
|
||||
}
|
||||
|
||||
# #7680 re-gate (9/22): every site that scans bare MEDIA: tokens also
|
||||
# runs the wrapped-form pre-pass (`` `MEDIA:path` `` → ``MEDIA:path``)
|
||||
# so the closing backtick of an inline-code form is consumed before the
|
||||
# bare scan.
|
||||
JS_BACKTICK_PREPASS: dict[str, str] = {
|
||||
"static/ui.js": r"/`MEDIA:([^`\s]+)`/g",
|
||||
"static/messages.js": r"/`MEDIA:([^`\s]+)`/g",
|
||||
}
|
||||
PY_BACKTICK_PREPASS: dict[str, str] = {
|
||||
"api/routes.py": r"`MEDIA:([^`\s]+)`",
|
||||
"api/media_snapshots.py": r"`MEDIA:([^`\s]+)`",
|
||||
}
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-shape: every site must carry the bare-token class + the pre-pass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("relpath", "needle"),
|
||||
[
|
||||
(relpath, needle)
|
||||
for relpath, needles in JS_SITES.items()
|
||||
for needle in needles
|
||||
],
|
||||
)
|
||||
def test_js_site_carries_bare_token_class(relpath: str, needle: str) -> None:
|
||||
"""Every JS capture site carries the bare-token class literal."""
|
||||
src = _read(relpath)
|
||||
assert needle in src, (
|
||||
f"{relpath} is missing the bare-token capture literal: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("relpath", "needle"),
|
||||
[
|
||||
(relpath, JS_BACKTICK_PREPASS[relpath])
|
||||
for relpath in JS_BACKTICK_PREPASS
|
||||
],
|
||||
)
|
||||
def test_js_site_carries_wrapped_prepass(relpath: str, needle: str) -> None:
|
||||
"""#7680 re-gate: every JS site that scans bare MEDIA: tokens also
|
||||
runs the wrapped-form pre-pass so the inline-code closing backtick
|
||||
is eaten before the bare scan."""
|
||||
src = _read(relpath)
|
||||
assert needle in src, (
|
||||
f"{relpath} is missing the wrapped-form pre-pass literal: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("relpath", "needle"),
|
||||
[(relpath, PY_SITES[relpath]) for relpath in PY_SITES],
|
||||
)
|
||||
def test_py_site_carries_bare_token_class(relpath: str, needle: str) -> None:
|
||||
src = _read(relpath)
|
||||
assert needle in src, (
|
||||
f"{relpath} is missing the bare-token capture literal: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("relpath", "needle"),
|
||||
[(relpath, PY_BACKTICK_PREPASS[relpath]) for relpath in PY_BACKTICK_PREPASS],
|
||||
)
|
||||
def test_py_site_carries_wrapped_prepass(relpath: str, needle: str) -> None:
|
||||
src = _read(relpath)
|
||||
assert needle in src, (
|
||||
f"{relpath} is missing the wrapped-form pre-pass literal: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_all_seven_sites_covered() -> None:
|
||||
"""Pin: 5 JS sites + 2 Python sites = 7 capture sites, all guarded."""
|
||||
js_total = sum(len(v) for v in JS_SITES.values())
|
||||
assert js_total == 5, (
|
||||
f"expected 5 JS capture sites, got {js_total}; this count is what the "
|
||||
"review's '7 capture sites' was counting (1 linkifier + 1 TTS + 3 messages.js)"
|
||||
)
|
||||
assert len(PY_SITES) == 2
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _media_token_re() -> re.Pattern[str]:
|
||||
"""Compile the live ``_MEDIA_TOKEN_RE`` from api/routes.py (plus the
|
||||
pre-pass) so behaviour tests exercise the real source."""
|
||||
routes_text = _read("api/routes.py")
|
||||
m = re.search(
|
||||
r'_MEDIA_TOKEN_RE = re\.compile\(r"([^"]+)"\)', routes_text, re.M
|
||||
)
|
||||
assert m, "_MEDIA_TOKEN_RE compile() not found in api/routes.py"
|
||||
bare = re.compile(m.group(1))
|
||||
pre = re.compile(r"`MEDIA:([^`\s]+)`")
|
||||
return _TwoPass(bare, pre)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _media_snap_re() -> re.Pattern[str]:
|
||||
"""Compile the live ``media_re`` from api/media_snapshots.py (plus the
|
||||
pre-pass) so behaviour tests exercise the real source."""
|
||||
snap_text = _read("api/media_snapshots.py")
|
||||
m = re.search(
|
||||
r'media_re = _re\.compile\(r"([^"]+)"\)', snap_text, re.M
|
||||
)
|
||||
assert m, "media_re compile() not found in api/media_snapshots.py"
|
||||
bare = re.compile(m.group(1))
|
||||
pre = re.compile(r"`MEDIA:([^`\s]+)`")
|
||||
return _TwoPass(bare, pre)
|
||||
|
||||
|
||||
class _TwoPass:
|
||||
"""Compose the wrapped-form pre-pass with the bare-token class so the
|
||||
behaviour tests below exercise the live source exactly as it runs."""
|
||||
|
||||
def __init__(self, bare: re.Pattern[str], prepass: re.Pattern[str]):
|
||||
self._bare = bare
|
||||
self._pre = prepass
|
||||
|
||||
def findall(self, text: str) -> list[str]:
|
||||
text = self._pre.sub(lambda m: f"MEDIA:{m.group(1)}", text)
|
||||
return self._bare.findall(text)
|
||||
|
||||
|
||||
def test_backend_allowlist_strips_backtick(_media_token_re) -> None:
|
||||
"""``_MEDIA_TOKEN_RE`` (with the pre-pass) must not include the closing
|
||||
backtick in the captured path."""
|
||||
captured = _media_token_re.findall("get `MEDIA:/home/kim/skills.zip` now")
|
||||
assert captured == ["/home/kim/skills.zip"], captured
|
||||
assert all(not c.endswith("`") for c in captured)
|
||||
|
||||
|
||||
def test_backend_allowlist_bare_token_still_captures(_media_token_re) -> None:
|
||||
"""Backward-compat: bare ``MEDIA:/path`` (no backtick) still matches."""
|
||||
captured = _media_token_re.findall(
|
||||
"MEDIA:/home/kim/a.zip and MEDIA:/home/kim/b.tar"
|
||||
)
|
||||
assert captured == ["/home/kim/a.zip", "/home/kim/b.tar"], captured
|
||||
|
||||
|
||||
def test_backend_allowlist_bare_path_with_backtick_in_filename(
|
||||
_media_token_re,
|
||||
) -> None:
|
||||
"""#7680 finding 1: a bare ``MEDIA:/tmp/report`final.png`` (backtick
|
||||
inside the filename, no surrounding wrapper) must capture the full
|
||||
path. POSIX and Windows both allow backticks in filenames, so
|
||||
truncating at the first backtick silently bricks the request."""
|
||||
captured = _media_token_re.findall("see MEDIA:/tmp/report`final.png now")
|
||||
assert captured == ["/tmp/report`final.png"], captured
|
||||
assert not any(c.endswith("`") and c != "/tmp/report`final.png" for c in captured), captured
|
||||
|
||||
|
||||
def test_backend_allowlist_rejects_trailing_paren(_media_token_re) -> None:
|
||||
"""Pre-existing behaviour: ``)`` is still excluded."""
|
||||
captured = _media_token_re.findall("see [file](MEDIA:/home/kim/a.zip) here")
|
||||
assert all(not c.endswith(")") for c in captured), captured
|
||||
|
||||
|
||||
def test_snapshot_capture_strips_backtick(_media_snap_re) -> None:
|
||||
"""``media_re`` (with the pre-pass) must not include the closing
|
||||
backtick in the captured path."""
|
||||
captured = _media_snap_re.findall(
|
||||
"assistant: `MEDIA:/home/kim/shot.png` and MEDIA:/home/kim/a.zip"
|
||||
)
|
||||
assert captured == ["/home/kim/shot.png", "/home/kim/a.zip"], captured
|
||||
assert all(not c.endswith("`") for c in captured)
|
||||
|
||||
|
||||
def test_snapshot_capture_bare_path_with_backtick_in_filename(
|
||||
_media_snap_re,
|
||||
) -> None:
|
||||
"""#7680 finding 1: same bare-with-backtick case for the snapshot
|
||||
pipeline. Without the pre-pass + bare class change, the snapshot
|
||||
store would never see this path because the auth predicate and
|
||||
the capture predicate must agree on the same set of references."""
|
||||
captured = _media_snap_re.findall("MEDIA:/home/kim/shot`final.png tail")
|
||||
assert captured == ["/home/kim/shot`final.png"], captured
|
||||
|
||||
|
||||
def test_snapshot_capture_bare_token_still_captures(_media_snap_re) -> None:
|
||||
"""Backward-compat: bare ``MEDIA:/path`` still matches."""
|
||||
captured = _media_snap_re.findall(
|
||||
"MEDIA:/home/kim/a.zip and MEDIA:/home/kim/b.zip"
|
||||
)
|
||||
assert captured == ["/home/kim/a.zip", "/home/kim/b.zip"], captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revert-sensitivity: the pre-fix class really would have truncated
|
||||
# the bare-with-backtick case, and the round-1 fix would have left the
|
||||
# wrapped form broken in a different way.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_round1_buggy_class_truncated_bare_path_with_backtick() -> None:
|
||||
"""The buggy (round-1) class greedily truncated at the first backtick
|
||||
in the path, so ``MEDIA:/tmp/report`final.png`` was captured as
|
||||
``/tmp/report``. This is the regression we are guarding against."""
|
||||
buggy = re.compile(r"MEDIA:([^\s\)\]\`]+)")
|
||||
assert buggy.findall("MEDIA:/tmp/report`final.png") == ["/tmp/report"], (
|
||||
"round-1 class is no longer buggy; if you are intentionally reverting "
|
||||
"to it, also remove the corresponding `test_post_fix_class_keeps_"
|
||||
"bare_path` and update the source-shape guard."
|
||||
)
|
||||
|
||||
|
||||
def test_round1_buggy_class_dropped_wrapped_closing_backtick() -> None:
|
||||
"""The round-1 class was actually correct for the wrapped form: it
|
||||
dropped the closing backtick that the round-0 class greedily
|
||||
included. Document the behaviour so a future revert picks the
|
||||
correct replacement."""
|
||||
round1 = re.compile(r"MEDIA:([^\s\)\]\`]+)")
|
||||
assert round1.findall("`MEDIA:/home/kim/a.zip`") == ["/home/kim/a.zip"]
|
||||
|
||||
|
||||
def test_round1_two_pass_compose_keeps_bare_path_intact() -> None:
|
||||
"""The full #7680 fix is the two-pass compose: pre-pass strips the
|
||||
wrapped form, then the bare class (no backtick in the exclusion
|
||||
set) captures the full path. Verify the composition on both
|
||||
cases so a future refactor that drops either half is caught."""
|
||||
pre = re.compile(r"`MEDIA:([^`\s]+)`")
|
||||
bare = re.compile(r"MEDIA:([^\s\)\]]+)")
|
||||
|
||||
def findall_composed(text: str) -> list[str]:
|
||||
text = pre.sub(lambda m: f"MEDIA:{m.group(1)}", text)
|
||||
return bare.findall(text)
|
||||
|
||||
# bare-with-backtick: pre-pass doesn't touch it, bare captures full path
|
||||
assert findall_composed("MEDIA:/tmp/report`final.png") == [
|
||||
"/tmp/report`final.png"
|
||||
]
|
||||
# wrapped: pre-pass strips the backticks, bare captures the path
|
||||
assert findall_composed("`MEDIA:/tmp/a.zip`") == ["/tmp/a.zip"]
|
||||
# mixed
|
||||
assert findall_composed(
|
||||
"see `MEDIA:/home/kim/shot.png` and MEDIA:/tmp/r`f.png tail"
|
||||
) == ["/home/kim/shot.png", "/tmp/r`f.png"]
|
||||
# plain bare: unchanged
|
||||
assert findall_composed("MEDIA:/home/kim/a.zip") == ["/home/kim/a.zip"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# node --check (so a JS syntax error in the modified source fails the suite)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relpath", sorted(JS_SITES.keys()))
|
||||
def test_js_file_parses(relpath: str) -> None:
|
||||
"""``node --check`` on every modified JS file."""
|
||||
proc = subprocess.run(
|
||||
["node", "--check", str(REPO_ROOT / relpath)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
f"{relpath} failed `node --check`:\n{proc.stderr}"
|
||||
)
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user