Compare commits

..
Author SHA1 Message Date
Teknium 3ef6bbd201 chore: release v0.19.0 (2026.7.20) (#68175)
Publish to PyPI / Build distribution 📦 (push) Has been cancelled
Deploy Site / deploy-vercel (release) Has been cancelled
Deploy Site / deploy-docs (release) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (release) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (release) Has been cancelled
Publish to PyPI / Publish to PyPI (push) Has been cancelled
Publish to PyPI / Sign and attach to GitHub Release (push) Has been cancelled
Docker Build, Test, and Publish / merge (release) Has been cancelled
2026-07-20 11:35:21 -07:00
ethernet 18ca0e862c Merge pull request #66471 from NousResearch/ethie/typescript-lsp
fix(lsp): never report stale diagnostics — version-gated freshness for slow servers
2026-07-20 14:16:50 -04:00
Teknium 0e281b58e6 fix(matrix): class-level split-threshold defaults for partially-constructed adapters
Text-batching tests (and any tooling) build MatrixAdapter via
object.__new__ without running __init__; moving _split_threshold from a
class constant to an instance attribute made _flush_text_batch die with
AttributeError, silently dropping the flush. Restore class-level
defaults (max_message_length, _split_threshold) that __init__ overrides,
and derive the near-limit test payload from adapter._split_threshold
instead of the old hardcoded 3950.
2026-07-20 11:10:49 -07:00
Teknium 086a56a028 fix(matrix): correct platform hint over-claims, add regression tests + docs
Follow-up to the salvaged #52552 and #53083 commits:

- Rework the Matrix PLATFORM_HINTS entry around what the adapter actually
  emits: headings, numbered lists, blockquotes, strikethrough-free markdown
  all render (the adapter converts them to sanctioned HTML). Keep the
  genuinely valuable guidance: no Markdown tables (Element X / Beeper /
  mobile clients don't render HTML tables — cells collapse into one line),
  no spoilers/checkboxes/~~strikethrough~~ (not converted by
  python-markdown), prefer descriptive link text.
- Regression test: hint must steer models away from tables.
- Fix test_long_response_split_preserves_thread_context to derive its
  payload size from the adapter's configurable limit instead of assuming
  the old hardcoded 4000.
- Document matrix.max_message_length in the Matrix docs page.
- Contributor mapping for RKelln.
2026-07-20 11:10:49 -07:00
nankingjing 35e0f56fbf fix(matrix): make outbound message length configurable (#53026)
Raise the Matrix adapter default chunk size from 4,000 to 16,000
characters and allow overrides via config.yaml or MATRIX_MAX_MESSAGE_LENGTH.

Fixes #53026
2026-07-20 11:10:49 -07:00
Ryan Kelln 08626c18be fix(prompt_builder): improve Matrix PLATFORM_HINTS with tested formatting rules
- Replace outdated brief hint with comprehensive, tested rules
- Document exactly what renders on Matrix and what does not
- Add critical linebreak semantics (two trailing spaces = soft break)
- Add link formatting guidance (descriptive text, never bare URLs)
2026-07-20 11:10:49 -07:00
Teknium a5b9803a51 test(transports): assert Moonshot wire schema carries required:[]
Transport-level regression for #66835 — verifies the outgoing tool
schema at the chat_completions build_kwargs boundary, not just the
sanitizer unit.
2026-07-20 11:05:52 -07:00
PRATHAMESH75 acc1b6e76a fix(agent): inject empty required array on Moonshot object schemas
Moonshot/Kimi's tool-parameter validator rejects object schemas that omit
the required key with HTTP 400 ("required must be an array"), even though
standard JSON Schema allows omitting it. Any Hermes tool with zero required
parameters (browser_back, delegate_task, project_list, several MCP list_*
tools, etc.) tripped this when routed to a Moonshot endpoint.

Add a Rule 4 to the sanitizer: every object schema gets a required array,
defaulting to []. Existing lists are preserved but pruned to names that
actually appear in properties (dangling entries are also rejected upstream).
Applied recursively to nested object schemas and to the coerced/empty
top-level fallback.

Fixes #66835
2026-07-20 11:05:52 -07:00
ethernet eb09df6ec8 refactor(lsp): version-tagged _DocState replaces timestamp freshness tracking
The staleness fix (f9b1fd799) bolted two wall-clock dicts (_changed_at,
_pulled_at) onto a client that already scattered per-document state
across six parallel dicts (_files, _push_diagnostics, _pull_diagnostics,
_published, _published_version, _first_push_seen) — eight maps kept in
sync by hand.

Collapse all of it into one _DocState per path, and use the LSP document
version as the freshness token instead of clocks:

- didChange bumps doc.version; stored push/pull results carry the
  version they describe (push_version from the server's echoed version,
  or the current version at receipt for servers that don't echo one;
  pull_version captured at request send so an in-flight pull that a
  didChange races past is stale on arrival).
- fresh == tag >= version. Invalidation is implicit in the bump — no
  store-clearing, no clock comparisons, no race windows.
- _has_fresh_push/_has_fresh_pull helpers dissolve into two one-line
  _DocState methods; diagnostics_for(fresh_only=True) becomes a
  three-liner.

Semantics are unchanged from f9b1fd799 (same tests pass, one test
updated off private internals); net -15 lines.
2026-07-20 13:43:36 -04:00
ethernet a632e68a01 fix(lsp): never report stale diagnostics — wait for fresh post-edit data
Slow language servers (tsserver on large projects especially) publish
diagnostics long after an edit. The client's wait/report path had three
holes that together surfaced the PREVIOUS edit's errors as if they were
current ("ghost diagnostics"), sending the agent chasing errors it had
already fixed:

1. open_file only cleared the diagnostic stores on first open — on the
   didChange path (every subsequent edit) stale push/pull entries
   survived.
2. wait_for_diagnostics' predicates were satisfiable by that leftover
   state (`path in _published`, `path in _pull_diagnostics`), so the
   "wait" often returned instantly with old data.
3. diagnostics_for merged the stale push store unconditionally, so even
   a fresh clean pull got the old error merged back in.

Fix: anchor freshness on a per-file didChange timestamp.

- Pull results record their request send-time and are dropped when a
  didChange raced past them; the pull store is invalidated on every
  change, not just first open.
- wait_for_diagnostics now returns bool (fresh data vs timeout), only
  counts pushes published at/after the change (and version >= ours when
  the server echoes versions), and accepts an explicit timeout — the
  user's lsp.wait_timeout config now actually controls the inner wait
  budget instead of only the outer thread-join.
- diagnostics_for(fresh_only=True) excludes stores that predate the
  latest change; all manager report paths use it.
- On timeout the manager returns [] ("no data") instead of stale
  state, logs a WARNING via eventlog, and does NOT mark the server
  broken — slow is not dead.
- seed-on-first-push no longer marks the file published, so the TS
  seed push can't satisfy a waiter.

Tests: new "stale" and "slow_push" mock-server scripts model the slow
tsserver, plus client- and service-level regression tests
(tests/agent/lsp/test_stale_diagnostics.py).
2026-07-20 13:43:36 -04:00
Teknium 456f18b19c fix(picker): scope exact-ID resolution to lossy alias collapses only
The cherry-picked resolve_provider_full 0.5 step returned a generic
openai_chat ProviderDef for ANY registry ID, hijacking single-entry
alias rewrites like copilot -> github-copilot away from their overlay
transports (test_explicit_copilot_switch_uses_selected_model_api_mode
regression). Restrict the early return to names where MULTIPLE registry
providers collapse to one canonical (kimi-coding + kimi-coding-cn +
kimi + moonshot -> kimi-for-coding) — the only case where alias
resolution actually loses information.

Also maps Almurat123's contributor email.
2026-07-20 10:17:57 -07:00
Almurat 52e16c1138 fix: preserve kimi-coding-cn provider identity 2026-07-20 10:17:57 -07:00
Almurat 2ffdf08376 fix: show both kimi-coding and kimi-coding-cn in /model picker
Both providers share the same models.dev ID (kimi-for-coding) but
have different API keys (KIMI_API_KEY vs KIMI_CN_API_KEY) and base
URLs (moonshot.ai vs moonshot.cn).  The /model picker was only
showing one because the dedup key was mdev_id alone.

Changes in list_authenticated_providers():
- Resolve canonical provider profile name and skip alias hermes_ids
  (e.g. "kimi", "moonshot" → "kimi-coding") so only canonical
  entries are processed.
- Deduplicate by slug (hermes_id) instead of mdev_id so distinct
  profiles sharing a models.dev ID (kimi-coding vs kimi-coding-cn)
  both appear.
- Prefer PROVIDER_REGISTRY name for the display label so the CN
  variant shows "Kimi / Moonshot (China)" instead of the generic
  models.dev name.

Adds test coverage for all three key scenarios:
- Only KIMI_CN_API_KEY set → only kimi-coding-cn appears
- Only KIMI_API_KEY set → only kimi-coding appears
- Both keys set → both providers appear, aliases not duplicated

Closes #10526
2026-07-20 10:17:57 -07:00
AIalliAI b99e1e3bf6 fix(model): collapse kimi alias/canonical to one /model picker row
A single Kimi credential surfaced two rows in the `/model` picker — the
bare alias `kimi` (PROVIDER_TO_MODELS_DEV pass) and the canonical
`kimi-coding` (CANONICAL_PROVIDERS cross-check, section 2b) — both backed
by the same `kimi-for-coding` provider.

`kimi`, `moonshot` and the canonical `kimi-coding` all map to one
models.dev id (`kimi-for-coding`). The seen_mdev_ids guard collapses them
to the first key in section 1, but that key is the bare alias, so 2b
re-emits the canonical name as a second row.

Emit the row under the canonical Hermes slug instead: resolve the alias
via _PROVIDER_ALIASES (`kimi` -> `kimi-coding`) before appending, so 2b's
seen_slugs check collapses the pair. This matches the picker's other alias
rows (copilot, gemini) and the overlay slug-resolution contract, and keeps
the surviving row resolvable to the real provider. A defensive seen_slugs
guard prevents emitting a duplicate canonical row.

Distinct providers keep their own row: `kimi-coding-cn` has its own
KIMI_CN_API_KEY and is still emitted by section 2b.

Regression tests assert the single-key case yields one `kimi-coding` row
(fails on clean main, which shows both `kimi` and `kimi-coding`) and that
the China endpoint is preserved.

Fixes #49439
2026-07-20 10:17:57 -07:00
ethernet 3133af8215 fix(desktop): prevent duplicate messages when verification candidates are persisted (#68149)
The display_history_prefix calculation used by session.resume's
_live_session_payload was display_history[:len(display) - len(raw)].
This assumed the model (repaired) history is always a suffix of the
display history — i.e., repair_message_sequence only removes messages
from the tail. That assumption broke when verification candidates
(finish_reason=verification_required) were persisted to state.db (#65919):

  - repair collapses consecutive assistant messages, removing the
    verification candidate from the MODEL history
  - the candidate stays in the DISPLAY history (it's real persisted content)
  - the length gap (gap = len(display) - len(raw)) counts BOTH ancestor
    messages AND repair-removed tip messages
  - the prefix = display[:gap] grabs the first N display messages, which
    are tip messages (not ancestors) when there are no compression ancestors
  - _live_session_payload concatenates prefix + model_history, duplicating
    the first N messages

On session 20260720_110036_a33889 (8 verification candidates), this
duplicated the first 8 messages in every warm-cache session.activate
response, producing visible duplicate user messages in the desktop.

Fix: add SessionDB.get_ancestor_display_prefix() which returns ONLY
genuine ancestor messages (rows where session_id != tip_session_id),
identified at the row level before _rows_to_conversation strips
session_id. Both resume paths (eager + deferred) now use this instead
of the length-slice heuristic.

Tests:
  - test_get_ancestor_display_prefix_single_session_returns_empty
  - test_get_ancestor_display_prefix_returns_ancestor_only_messages
  - Updated 12 mock DBs across test_protocol.py + test_tui_gateway_server.py
  - 848 passed (run_tests.sh), 0 regressions
2026-07-20 17:14:41 +00:00
nousbot-engandgithub-actions[bot] aa274364bb fmt(js): npm run fix on merge (#68135)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-20 16:46:35 +00:00
ethernet 470c7e2a60 fix(desktop): prevent timers from shifting as they count (#68131)
LiveDuration returned a bare string with proportional digits, so the
statusbar reflowed every second as the timer ticked. Extract a shared
StableText component that renders each character in its own 1ch-wide
cell, preventing any digit from shifting the layout — works with the
proportional sans font, no need for font-mono.

Both LiveDuration (statusbar session/running timers) and
ActivityTimerText (tool activity timers) now use StableText, dropping
the font-mono + tabular-nums workaround from the latter.

Also renames statusbar.ts → statusbar.tsx since LiveDuration now
returns JSX.
2026-07-20 16:39:20 +00:00
xxxigmandHermes Agent 9403b4f8ba fix(feishu): keep msg_type=post consistent across every chunk of a long markdown reply (#26841)
Transplant of PR #26848 onto the plugin adapter path
(plugins/platforms/feishu/adapter.py — the original PR targeted the
since-removed gateway/platforms/feishu.py).

``send`` classifies each chunk independently, so chunk 1 of a long
markdown reply (often plain prose) went out as msg_type=text while
later chunks rendered as post — literal **bold**/## heading markers
in the Feishu client. Lock the decision at the whole-message level:
compute prefer_post once from the full formatted message and pass it
to _build_outbound_payload per chunk.

The original PR's per-chunk table exemption is intentionally dropped:
tables now route through post/md (issue #52786 cluster fix), so the
exemption would reintroduce the raw-table downgrade.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-07-20 09:28:24 -07:00
Teknium 17a99f6b15 chore: map contributor ly-wang19 for #49551 salvage 2026-07-20 09:21:08 -07:00
Teknium c0dff40e3a test: use shutil.copy2 instead of os.link for cross-device tmp fixtures
TestPtyWebSocket's two python-resolution tests and the sibling fixture in
test_tui_resume_flow.py hard-linked sys.executable into pytest's tmp_path.
On machines where /tmp is a different filesystem than the venv (tmpfs vs
disk home) os.link raises OSError EXDEV and the tests fail before reaching
any assertion. copy2 preserves the executable bit and works across devices.
2026-07-20 09:21:08 -07:00
Teknium faa4cec01b fix(credentials): hoist read-guard import, fail closed loudly (#67665)
Follow-up to #67640: move the agent.file_safety import to module top
(stdlib-only, no circular-import concern), replace the over-broad
except Exception + logger.warning with an import sentinel plus
logger.exception so a guard failure is debuggable instead of silently
swallowed. Adds fail-closed tests asserting the diagnostic is emitted.
2026-07-20 09:21:08 -07:00
ly-wang19 28028cce55 fix(web): clear stale api-alias credential on provider switch in main-model assignment
c253b0738 added clear_model_endpoint_credentials() to scrub an old endpoint's
inline secret (api_key, the legacy `api` alias, api_mode) when the web UI
switches the main model to a different provider. But _apply_main_model_assignment
gates the key-scrub path on model_cfg["api_key"] being truthy, so when the stale
secret lives only under the legacy `api` alias (no api_key), a provider switch
never clears it — the secret survives in config.yaml.

model.api is a live credential read path (_resolve_openrouter_runtime reads
`for k in ("api_key", "api")`), so the old endpoint's key contaminates a later
custom resolution — the exact harm clear_model_endpoint_credentials documents.
The sibling persistence sites (the gateway model-picker paths and the aux-slot
path) call the helper unconditionally on a non-custom switch and already scrub
`api`; only this caller had the api_key-only gate.

Widen the guard to fire on either field. The same-provider re-pick and
explicit-new-key paths are unchanged. Adds the api-alias case to the assignment
test (it fails without the fix).
2026-07-20 09:21:08 -07:00
Teknium 977884e6cd chore: add contributor email mappings for PR #58019 / #29552 salvage 2026-07-20 09:19:24 -07:00
M1racleShih ae22a03ef6 fix(feishu): render markdown tables via post md
Route table-shaped Markdown through the existing post/md builder so current Feishu clients render tables instead of showing source markup.

Add a direct payload regression test that checks the post message type and decoded md element.
2026-07-20 09:19:24 -07:00
JasonFang1993 a660630986 fix(feishu): render markdown tables via post+md, not text downgrade
Resolves issue #52786 (duplicate of #23938):

The `_build_outbound_payload` shortcut forced any message containing a
pipe table to ``msg_type=text``.  Feishu readers then rendered the raw
pipe-and-dash source instead of a table.  Empirically current Feishu
clients render markdown tables inside ``post``-type ``md`` elements
natively, so the downgrade branch had to go.

Two changes:

1. ``_MARKDOWN_HINT_RE`` now also matches a pipe-table header+separator
   pair, so a table-only message is recognised as "has markdown" and
   takes the ``post`` path.  All previously recognised hints (headings,
   lists, code, bold/italic/strike/underline, links, blockquotes, hr)
   still match — verified by the existing 205 test_feishu.py cases plus
   the new regression tests below.

2. ``_build_outbound_payload`` no longer special-cases `_MARKDOWN_TABLE_RE`
   before the hint check.  The hint check now routes table content to
   `_build_markdown_post_payload`, which is the same path any other
   markdown structure takes.

``_MARKDOWN_TABLE_RE`` itself is retained as a module-level constant for
external callers (import-path-sensitive tests, third-party consumers of
the adapter module) and continues to work for its existing uses.

Tests
-----
New: ``tests/gateway/test_feishu_table_markdown.py`` — four regression
tests:

- ``test_markdown_table_uses_post_not_text`` — pure-table content
  reaches ``post`` (issue #52786 scenario).
- ``test_table_combined_with_other_markdown_does_not_downgrade`` —
  prose + table + prose message keeps its surrounding markdown.
- ``test_existing_markdown_heading_still_uses_post`` — sanity guard:
  the heading path is unchanged.
- ``test_plain_text_without_markdown_still_uses_text`` — negative
  control: pure prose still goes to ``text``.

Verification
------------
``pytest tests/gateway/test_feishu.py
tests/gateway/test_feishu_table_markdown.py`` passes 209/209 (205
existing + 4 new), three consecutive runs.

Rollback
--------
``git reset --hard 44ddc552f5e054759a6970af8997ea588a9d81c9``
restores upstream main without the new test file.
2026-07-20 09:19:24 -07:00
Teknium 5e999b98cb test: fix stale k3 fixture in deepseek signed-thinking replay test
test_deepseek_still_strips_signed_thinking passed model='k3' with the
DeepSeek base URL — that only held because bare 'k3' wasn't classified
as Kimi family yet. With k3 now correctly classified, the kimi-family
model-name path (deliberate: proxied endpoints preserve thinking,
#13848/#17057) keeps the blocks. Use a real DeepSeek slug for the
DeepSeek behavior, and add an explicit invariant test that Kimi-family
slugs (named and bare) keep thinking on foreign gateway hostnames.
2026-07-20 08:47:55 -07:00
Teknium 25eafd7d71 fix(models): complete kimi-k3 rollout across Kimi-direct catalog surfaces
Follow-up widening for salvaged PRs #67115, #67685, #67620:

- _PROVIDER_MODELS: add kimi-k3 atop kimi-coding / moonshot / opencode-go
  curated lists (kimi-coding-cn covered by cherry-picked #67620)
- setup.py _DEFAULT_PROVIDER_MODELS: kimi-k3 for kimi-coding(-cn) + opencode-go
- model_metadata: align DEFAULT_CONTEXT_LENGTHS kimi-k3 entry to 1,048,576
  (matches endpoint-scoped override, models.dev, and OpenRouter live metadata)
- anthropic_adapter: classify the bare Coding Plan slug 'k3' (and k3.x/k3-*)
  as Kimi family so adaptive thinking applies on proxied endpoints
- moonshot_schema: is_moonshot_model matches bare 'k3' so tool-schema
  sanitization runs on the chat-completions path
- contributor mappings for githubespresso407, datachainsystems, Punyko8

Tests: 582 passed across 11 targeted files; hermetic E2E verifies picker
order (kimi-k3 first), no dupes, and 1M context resolution.
2026-07-20 08:47:55 -07:00
Punyko8 495d4acec5 feat: add kimi-k3 + kimi-k2.7-code to kimi-coding-cn whitelist
Moonshot China (api.moonshot.cn) has rolled out kimi-k3 to all
CN-endpoint keys, plus the new kimi-k2.7-code / -highspeed variants.
The kimi-coding-cn curated picker whitelist was still on the k2.6/k2.5
era list, so users holding CN keys could not select any of the new
models from 'hermes model' or the gateway /model picker even though
the underlying provider + endpoint already serve them.

Verified against a live CN key:

    GET https://api.moonshot.cn/v1/models
    -> [kimi-k3, kimi-k2.7-code, kimi-k2.7-code-highspeed, kimi-k2.6,
        kimi-k2.5, moonshot-v1-* ...]

No provider-code changes; pure whitelist addition mirroring the
existing kimi-coding (global) list which already tracks k2.7-code.
2026-07-20 08:47:55 -07:00
datachainsystems 54c39c0301 fix: add Kimi K3 1M context window to DEFAULT_CONTEXT_LENGTHS
Kimi K3 ships with a 1M-token context window (verified against
platform.kimi.ai/docs/overview) but was falling through to the generic
'kimi': 262144 catch-all. Added 'kimi-k3': 1_000_000 before the catch-all
so longest-key-first substring matching resolves K3 to 1M while older
Kimi models still hit the 256K default.

Added matching test_kimi_k3_context_1m test covering native,
vendor-prefixed (kimi/, moonshotai/), and older model fallback.
2026-07-20 08:47:55 -07:00
githubespresso407 77aa026ca6 Resolve kimi-k3 context length to 1M on canonical Kimi Coding endpoints
Kimi Coding serves K3 under the bare slug 'k3', but users can also
configure or select the public-facing aliases 'kimi-k3' and
'kimi-k3-cot'. The endpoint-scoped 1M context window was only keyed
on the bare 'k3' slug, so selecting 'kimi-k3' fell through to the
generic 'kimi' catch-all (262k).

Extend the guard in _endpoint_scoped_context_length to also recognize
'kimi-k3' and 'kimi-k3-cot', while keeping the endpoint check that
limits the 1M value to https://api.kimi.com/coding (legacy Moonshot
endpoints still fall back to 262k). Update the existing test to cover
all three aliases.

Fixes: context window limited to 262k when using kimi-k3 via kimi-coding.
2026-07-20 08:47:55 -07:00
107 changed files with 1870 additions and 2970 deletions
-7
View File
@@ -87,12 +87,6 @@ jobs:
uses: ./.github/workflows/js-tests.yml
secrets: inherit
e2e-desktop:
name: Desktop E2E
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/e2e-desktop.yml
docs-site:
name: Docs Site
needs: detect
@@ -171,7 +165,6 @@ jobs:
- tests
- lint
- js-tests
- e2e-desktop
- docs-site
- history-check
- contributor-check
-212
View File
@@ -1,212 +0,0 @@
name: E2E Desktop
on:
workflow_call:
permissions:
contents: read
concurrency:
group: e2e-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
name: Playwright E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ── System deps for Electron on headless Ubuntu ───────────────────
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
# install-deps covers browsers; for Electron we install the apt
# packages directly.
- name: Install system dependencies for Electron
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
xvfb \
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
# ── Node ───────────────────────────────────────────────────────────
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Full npm ci (not --ignore-scripts): electron's postinstall
# downloads the binary we launch, and node-pty's native build is
# needed for the terminal pane.
- uses: ./.github/actions/retry
with:
command: npm ci
# ── Python (for the hermes serve backend) ──────────────────────────
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install Python dependencies
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra all --extra dev
# ── Build desktop app ─────────────────────────────────────────────
- run: npm run --prefix apps/desktop build
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
# On PRs, we restore them so toHaveScreenshot has something to compare
# against. The cache key is keyed on the desktop source files so a
# UI change naturally invalidates it — but we fall back to the main
# cache to avoid cold starts on unrelated PRs.
- name: Restore visual baseline screenshots
id: restore-baselines
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-${{ github.ref_name }}
restore-keys: |
visual-baselines-main
# ── Run Playwright E2E under xvfb ─────────────────────────────────
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
CI: "true"
# Ensure no real API keys leak into the test env.
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
# ── Save updated baselines to cache (main only) ───────────────────
- name: Save updated baselines to cache
if: github.ref_name == 'main' && always()
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/desktop/e2e/*-snapshots
key: visual-baselines-main
# ── Upload Playwright report (HTML + traces) ──────────────────────
- name: Upload Playwright report
id: upload-report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ github.sha }}
path: apps/desktop/playwright-report
retention-days: 14
overwrite: true
# ── Upload test results (screenshots, traces, diffs) ───────────────
- name: Upload test results
id: upload-results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-test-results-${{ github.sha }}
path: apps/desktop/test-results
retention-days: 14
overwrite: true
# ── Upload just the visual diffs (small, fast to review) ──────────
- name: Upload visual diffs
id: upload-diffs
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: visual-diffs-${{ github.sha }}
path: |
apps/desktop/test-results/**/*-diff.png
apps/desktop/test-results/**/*-actual.png
apps/desktop/test-results/**/*-expected.png
retention-days: 14
overwrite: true
if-no-files-found: ignore
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
# without downloading artifacts. Runs AFTER uploads so it can link
# the artifact download URLs from their step outputs.
- name: Generate visual diff summary
if: always()
working-directory: apps/desktop
env:
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Count diff images (playwright writes *-diff.png on mismatch)
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
if [ "$DIFF_COUNT" -eq 0 ]; then
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
else
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
# List each diff image with a link to the artifact
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
base=$(echo "$diff" | sed 's/-diff\.png$//')
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
done
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$RESULTS_URL" ]; then
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$REPORT_URL" ]; then
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$DIFFS_URL" ]; then
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
node -e "
const r = require('./playwright-report/results.json');
const stats = r.stats || {};
console.log('| Status | Count |');
console.log('|--------|-------|');
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
fi
-396
View File
@@ -1,396 +0,0 @@
name: E2E Windows Desktop
on:
push:
branches: [ethie/e2e]
workflow_dispatch:
concurrency:
group: e2e-windows-${{ github.ref }}
cancel-in-progress: true
jobs:
# this is separated so we don't have node.js and stuff polluting the system
build-installer:
if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it.
name: Build Hermes-Setup.exe
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: checkout cache inputs
uses: actions/checkout@v4
with:
sparse-checkout: |
package-lock.json
apps/bootstrap-installer
sparse-checkout-cone-mode: true
# The cache key is the exact installer build fingerprint. A hit means
# this package-lock + bootstrap-installer source combo was already built,
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
- name: Restore installer build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
id: installer-cache
with:
path: Hermes-Setup.exe
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
- name: Setup Node.js
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Setup Rust
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: dtolnay/rust-toolchain@1.96.0 # stable
- name: checkout full tree on cache miss
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/checkout@v4
- name: Install npm dependencies
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build dev installer for this branch
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm run tauri:build
working-directory: apps/bootstrap-installer
timeout-minutes: 10
# Only runs on cache miss. Pick the exe the Tauri build produced and
# normalize its name so downstream jobs always know what to download.
- name: Normalize installer artifact name
if: steps.installer-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$candidates = @(
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
)
$installer = $null
foreach ($c in $candidates) {
if (Test-Path $c) {
$installer = Resolve-Path $c
break
}
}
if (-not $installer) {
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $installer) {
throw 'Could not find built Hermes-Setup.exe'
}
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
e2e:
name: Install this commit as latest main
if: false # NOTE: this job isn't used yet.
# needs: build-installer
runs-on: windows-latest
timeout-minutes: 60
env:
# Isolated install directory so the real install flow doesn't touch the
# runner's user profile. Kept under the workspace for easy cleanup.
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
steps:
- uses: actions/checkout@v4
with:
path: source
- name: Restore installer from build cache
if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website
id: installer-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: Hermes-Setup.exe
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
- name: Download latest production installer
id: download-installer
shell: pwsh
run: |
Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe
- name: Restore cached test tools
id: test-tools-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: test-bins
key: test-bins-${{ runner.os }}-v1
- name: Install AutoHotkey v2 and ffmpeg
if: steps.test-tools-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
# Install fresh when the cache missed.
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
if (-not (Test-Path $ahkDir)) {
throw "AutoHotkey install directory not found: $ahkDir"
}
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
# ffmpeg : just install into dir
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
- name: Add test-bins to PATH
shell: pwsh
run: |
ls "$PWD\test-bins\ffmpeg"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
# actions/checkout already has the right commit; just mirror it into the
# isolated home so the installer doesn't need to reach GitHub.
- name: Move checked-out workspace into isolated HERMES_HOME
shell: pwsh
run: |
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
# ── Run the headed installer + AHK helper ───────────────────────
- name: Launch Hermes-Setup.exe and install it
shell: pwsh
timeout-minutes: 10
env:
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
run: |
$installer = "Hermes-Setup.exe"
# ── Start screen recording (live stdin pipe) ──────────────────
# ffmpeg must be started, fed, and stopped from the SAME step: the
# graceful-stop signal is the character 'q' written to ffmpeg's live
# stdin. A separate teardown step can't do this because the process
# that owns the writable stdin pipe dies when this step ends.
#
# Start-Process / -RedirectStandardInput <file> does NOT work: it
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
# the file on disk never reaches the running process. We need a real
# writable pipe, which only System.Diagnostics.Process exposes.
#
# -pix_fmt yuv420p keeps
# the output broadly playable.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "ffmpeg"
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " +
"-hide_banner -loglevel error " +
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
$ffmpeg.Id | Out-File ffmpeg.pid
# Note: stderr is intentionally left attached to the console so it is
# captured in the step log. Do NOT redirect a stream we don't drain --
# ffmpeg's progress output would fill the pipe buffer and block.
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id ))"
$e2eDir = "$env:RUNNER_TEMP\e2e-windows"
Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse
$installerSuccess = $false
try {
# Launch the real installer
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
$proc.Id | Out-File installer.pid
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
-ArgumentList "$e2eDir\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow
# Wait for AHK helper to finish, and tail logs.
$logReader = $null
$logStream = $null
$logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log"
# can take a long time for installer!
$deadline = (Get-Date).AddSeconds(60 * 8)
try {
while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) {
if (-not $logReader) {
if (Test-Path $logPath) {
Write-Host "Found bootstrap-installer.log; tailing..."
# FileShare.ReadWrite is required: the installer almost
# certainly still has the file open for writing, and a
# plain Get-Content/File.Open would throw or lock it out.
$logStream = [System.IO.File]::Open(
$logPath, 'Open', 'Read', 'ReadWrite')
$logReader = New-Object System.IO.StreamReader($logStream)
}
} else {
$line = $logReader.ReadLine()
while ($null -ne $line) {
Write-Host "[bootstrap] $line"
$line = $logReader.ReadLine()
}
}
Start-Sleep -Milliseconds 500
}
# Drain anything written in the final tick before exit/timeout.
if ($logReader) {
$line = $logReader.ReadLine()
while ($null -ne $line) {
Write-Host "[bootstrap] $line"
$line = $logReader.ReadLine()
}
}
}
finally {
if ($logReader) { $logReader.Dispose() }
if ($logStream) { $logStream.Dispose() }
}
if (-not $ahkProc.HasExited) {
Write-Host "AHK helper is still running; stopping it"
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
} else {
Write-Host "autohotkey helper exited"
}
}
finally {
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
# the container header/index are finalized and the mkv is playable.
# This runs in the same step that owns the pipe, even on failure.
if ($ffmpeg -and -not $ffmpeg.HasExited) {
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
try {
$ffmpeg.StandardInput.Write("q")
$ffmpeg.StandardInput.Close()
} catch {
Write-Host "Failed to write q to ffmpeg stdin: $_"
}
if (-not $ffmpeg.WaitForExit(15000)) {
Write-Host "ffmpeg did not exit after 15s; killing"
$ffmpeg.Kill()
}
}
Write-Host "ffmpeg stopped"
# Installer should have exited
if ($proc.HasExited) {
# TODO check exit code once we add exit code in installer
$installerSuccess = $true
} else {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
throw "Installer is still running. Install did not succeed."
}
if (-not $installerSuccess) {
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
}
}
# ── Run Playwright against the installed binary ─────────────────
# (placeholder: will be enabled once installer completes successfully.)
- name: Launch installed app and run e2e
if: false
working-directory: source/apps/desktop
run: npm exec playwright test e2e/ --reporter=list
env:
# Point the e2e spec at the desktop binary that the installer built.
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
# ── Teardown & artifacts ────────────────────────────────────────
# ffmpeg is normally started AND gracefully stopped inside the launch
# step (so 'q' reaches its live stdin pipe). This step is only a
# safety net: if the launch step timed out or crashed before its
# finally block ran, force-kill any orphaned ffmpeg so the runner can
# release recording.mkv for upload. The mkv container survives a hard
# kill (only the trailing seek index is lost), so the artifact is still
# usable for coordinate discovery even on this fallback path.
- name: Stop orphaned screen recording (safety net)
if: always()
shell: pwsh
run: |
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
if ($ffmpegpid) {
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
} else {
Write-Host "ffmpeg already exited cleanly; nothing to do"
}
}
- name: Burn debug overlay into recording
if: always()
shell: pwsh
run: |
$logPath = Join-Path $PWD 'ahk.log'
$x = $y = $w = $h = $null
if (Test-Path $logPath) {
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
if ($line) {
$x = [int]$line.Matches[0].Groups[1].Value
$y = [int]$line.Matches[0].Groups[2].Value
$w = [int]$line.Matches[0].Groups[3].Value
$h = [int]$line.Matches[0].Groups[4].Value
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
} else {
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
}
} else {
Write-Host "ahk.log not found; only timestamp will be burned"
}
# Build the timestamp overlay
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
# Window border + 16px grid only over the window + axis labels
$grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
}
Write-Host "Overlay filter: $vf"
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
if ($LASTEXITCODE -ne 0) {
throw "ffmpeg overlay failed"
}
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
Write-Host "Overlayed recording saved as recording.mkv"
- name: Upload screen recording
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: upload-recording
if: always()
with:
name: screen-recording-${{ github.sha }}
path: recording.mkv
retention-days: 1
archive: false
overwrite: true
- name: Bootstrap Installer log
if: always()
shell: pwsh
run: |
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
- name: Autohotkey log
if: always()
shell: pwsh
run: |
Get-Content ahk.log
-6
View File
@@ -4,8 +4,6 @@
/_pycache/
*.pyc*
__pycache__/
act/
.act-sandbox-agent.*
.venv/
.venv
.vscode/
@@ -56,10 +54,6 @@ __pycache__/
hermes_agent.egg-info/
wandb/
testlogs
playwright-report/
test-results/
# Playwright visual regression baselines — cached from main in CI, not committed
*-snapshots/
# CLI config (may contain sensitive SSH paths)
cli-config.yaml
+2 -2
View File
@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.18.2",
"version": "0.19.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.18.2",
"package": "hermes-agent[acp]==0.19.0",
"args": ["hermes-acp"]
}
}
+10 -1
View File
@@ -457,7 +457,8 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
# Matched case-insensitively against the post-``normalize_model_name`` form,
# so a caller's ``provider/vendor/model`` slug is handled the same as a
# bare name.
@@ -467,8 +468,14 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
"k1.", "k1-",
"k2.", "k2-",
"k25", "k2.5",
"k3.", "k3-",
)
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
# merely start with the same characters don't get misclassified.
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
def _model_name_is_kimi_family(model: str | None) -> bool:
if not isinstance(model, str):
@@ -479,6 +486,8 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
if "/" in m:
m = m.rsplit("/", 1)[-1]
if m in _KIMI_FAMILY_EXACT_SLUGS:
return True
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
+140 -71
View File
@@ -18,9 +18,15 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
Implementation notes:
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
- All per-document state lives in one :class:`_DocState` keyed by
absolute path. Freshness is tracked with **document versions**,
not timestamps: every didChange bumps ``version``, and each stored
push/pull result is tagged with the version it describes. A
result is fresh iff its tag >= the version being waited on, so a
didChange implicitly invalidates everything older — no clearing,
no clock comparisons, no race windows. This is what prevents
"ghost diagnostics": a slow server's leftovers from the previous
edit can never masquerade as a verdict on the current content.
- Whole-document sync. Even when the server advertises incremental
sync, we send a single ``contentChanges`` entry replacing the
@@ -45,6 +51,7 @@ import asyncio
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote
@@ -124,6 +131,40 @@ def _end_position(text: str) -> Dict[str, int]:
return {"line": last_line, "character": last_col}
@dataclass
class _DocState:
"""Everything the client tracks for one open document.
``version`` is the LSP document version we last sent (didOpen=0,
each didChange +1). It doubles as the freshness token: stored
push/pull results are tagged with the version they describe
(``push_version`` / ``pull_version``), and a result is *fresh*
iff its tag has caught up to ``version``. Bumping the version on
didChange therefore invalidates all older results implicitly —
no store-clearing, no timestamps.
``push_version``/``pull_version`` start at -1 = "no data yet".
Servers that echo a document version in publishDiagnostics get
exact tagging; those that don't are credited with the current
version at receipt time (a push observed after we sent the
change describes the changed content or newer).
"""
version: int = 0
text: str = ""
push: List[Dict[str, Any]] = field(default_factory=list)
pull: List[Dict[str, Any]] = field(default_factory=list)
push_version: int = -1
pull_version: int = -1
seed_seen: bool = False
def fresh_push(self, version: Optional[int] = None) -> bool:
return self.push_version >= (self.version if version is None else version)
def fresh_pull(self, version: Optional[int] = None) -> bool:
return self.pull_version >= (self.version if version is None else version)
class LSPClient:
"""Async LSP client tied to one server process and one workspace root.
@@ -186,18 +227,10 @@ class LSPClient:
# is silently dropped by default.
}
# Tracked file state — required for didChange version bumps.
self._files: Dict[str, Dict[str, Any]] = {}
# Diagnostic stores, keyed by file path (NOT URI).
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
# Per-path "last published" time so wait-for-fresh logic works.
self._published: Dict[str, float] = {}
# Per-path version of the latest push (matches our didChange
# version when the server respects it).
self._published_version: Dict[str, int] = {}
# First-push seen flag, for typescript-style seed-on-first-push.
self._first_push_seen: Set[str] = set()
# Per-document state (version, text, diagnostic stores, and
# their freshness tags), keyed by absolute file path (NOT URI).
# See _DocState for the version-based freshness model.
self._docs: Dict[str, _DocState] = {}
# Capability registrations — only diagnostic ones are tracked.
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
@@ -647,25 +680,25 @@ class LSPClient:
if not isinstance(diagnostics, list):
diagnostics = []
version = params.get("version")
loop_time = asyncio.get_event_loop().time()
if self._seed_first_push and path not in self._first_push_seen:
# First push: seed without firing the event so a waiter
# doesn't resolve on the very first push (which arrives
# before the user-triggered didChange could've produced
# fresh diagnostics).
self._first_push_seen.add(path)
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
doc = self._docs.setdefault(path, _DocState(version=-1))
if self._seed_first_push and not doc.seed_seen:
# First push: seed the store WITHOUT a freshness tag. It
# arrives before the user-triggered didChange could've
# produced fresh diagnostics, so it must never satisfy a
# waiter — it's baseline data only.
doc.seed_seen = True
doc.push = diagnostics
return
self._push_diagnostics[path] = diagnostics
self._published[path] = loop_time
if isinstance(version, int):
self._published_version[path] = version
self._first_push_seen.add(path)
doc.seed_seen = True
doc.push = diagnostics
# Tag with the echoed document version when the server provides
# one; otherwise credit the current version — a push observed
# after we sent the change describes the changed content (or
# newer). Note doc.version is -1 for never-opened paths
# (e.g. relatedDocuments spillover), keeping them unfresh.
doc.push_version = version if isinstance(version, int) else doc.version
# Bump the monotonic push counter and wake every waiter. We
# keep the Event sticky-set so any wait already in progress
# resolves; waiters re-check their predicate after waking and
@@ -694,16 +727,16 @@ class LSPClient:
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
uri = file_uri(abs_path)
existing = self._files.get(abs_path)
doc = self._docs.get(abs_path)
if existing is not None:
if doc is not None and doc.version >= 0:
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
await self._send_notification(
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
)
new_version = existing["version"] + 1
old_text = existing["text"]
new_version = doc.version + 1
old_text = doc.text
content_changes: List[Dict[str, Any]]
if self._sync_kind == 2:
content_changes = [
@@ -724,7 +757,11 @@ class LSPClient:
"contentChanges": content_changes,
},
)
self._files[abs_path] = {"version": new_version, "text": text}
# Bumping the version is the whole invalidation story:
# every stored result tagged with an older version is now
# stale by definition (see _DocState).
doc.version = new_version
doc.text = text
return new_version
# First open: didChangeWatchedFiles CREATED + didOpen.
@@ -732,12 +769,9 @@ class LSPClient:
"workspace/didChangeWatchedFiles",
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
)
# Clear any stale push/pull entries — fresh open should start
# from scratch.
self._push_diagnostics.pop(abs_path, None)
self._pull_diagnostics.pop(abs_path, None)
self._published.pop(abs_path, None)
self._published_version.pop(abs_path, None)
# Fresh doc state — anything stashed under this path by a
# pre-open push (relatedDocuments spillover etc.) is discarded.
self._docs[abs_path] = _DocState(version=0, text=text)
await self._send_notification(
"textDocument/didOpen",
{
@@ -749,7 +783,6 @@ class LSPClient:
}
},
)
self._files[abs_path] = {"version": 0, "text": text}
return 0
async def save_file(self, path: str) -> None:
@@ -769,12 +802,19 @@ class LSPClient:
async def _pull_document_diagnostics(self, path: str) -> None:
"""Send ``textDocument/diagnostic`` for one file.
Stores results into :attr:`_pull_diagnostics`. Silently
no-ops on errors (server may not support the pull endpoint).
Stores results into the doc's pull store, tagged with the
document version captured at request send time. If a didChange
races past the in-flight request, the version bump makes the
stored result stale automatically — no explicit invalidation.
Silently no-ops on errors (server may not support the pull
endpoint).
"""
abs_path = os.path.abspath(path)
doc = self._docs.get(abs_path)
sent_version = doc.version if doc else -1
try:
params: Dict[str, Any] = {
"textDocument": {"uri": file_uri(os.path.abspath(path))}
"textDocument": {"uri": file_uri(abs_path)}
}
result = await self._send_request_with_retry(
"textDocument/diagnostic",
@@ -788,7 +828,9 @@ class LSPClient:
return
items = result.get("items")
if isinstance(items, list):
self._pull_diagnostics[os.path.abspath(path)] = items
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
doc.pull = items
doc.pull_version = sent_version
related = result.get("relatedDocuments")
if isinstance(related, dict):
for uri, sub in related.items():
@@ -796,7 +838,11 @@ class LSPClient:
continue
sub_items = sub.get("items")
if isinstance(sub_items, list):
self._pull_diagnostics[uri_to_path(uri)] = sub_items
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
rel.pull = sub_items
# Same send-anchored tagging: fresh only if that
# doc hasn't changed since the request went out.
rel.pull_version = rel.version
async def wait_for_diagnostics(
self,
@@ -804,22 +850,36 @@ class LSPClient:
version: int,
*,
mode: str = "document",
) -> None:
timeout: Optional[float] = None,
) -> bool:
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
``mode`` is ``"document"`` (5s budget, document pulls) or
``"full"`` (10s budget, also workspace pulls). Best-effort —
returns silently on timeout. Does NOT throw if the server
doesn't support pull diagnostics; we still get the push side.
``"full"`` (10s budget, also workspace pulls). ``timeout``
overrides the mode's default budget when provided — this is
how the user's ``lsp.wait_timeout`` config reaches the wait
loop (slow servers like tsserver on big projects need more
than the 5s default).
Returns ``True`` when *fresh* diagnostics arrived (a push at
or after our didChange, or a pull answered after it) and
``False`` on timeout. Callers must treat ``False`` as "no
data", NOT as "no errors" — the diagnostic stores may still
hold stale entries from the previous edit at that point.
Best-effort — never throws if the server doesn't support pull
diagnostics; we still get the push side.
"""
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
if timeout is not None and timeout > 0:
budget = timeout
else:
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
deadline = asyncio.get_event_loop().time() + budget
abs_path = os.path.abspath(path)
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return
return False
# Concurrent: document pull + push wait.
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
@@ -838,26 +898,24 @@ class LSPClient:
pass
# If we got a fresh push for our version, we're done.
current_v = self._published_version.get(abs_path)
if abs_path in self._published and (
current_v is None or current_v >= version
):
return
doc = self._docs.get(abs_path)
if doc and doc.fresh_push(version):
return True
# Pull may have populated _pull_diagnostics — that's also
# success.
if abs_path in self._pull_diagnostics:
return
# Pull may have answered for the current version — that's
# also success.
if doc and doc.fresh_pull(version):
return True
# Loop until budget runs out.
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
deadline = asyncio.get_event_loop().time() + timeout
baseline = self._push_counter
while True:
current_v = self._published_version.get(path)
if path in self._published and (current_v is None or current_v >= version):
doc = self._docs.get(path)
if doc and doc.fresh_push(version):
# Debounce — wait a tick in case more diagnostics arrive
# immediately after. TS often emits in pairs. We
# snapshot the counter so we wake on a *new* push, not
@@ -888,17 +946,28 @@ class LSPClient:
except asyncio.TimeoutError:
continue
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
"""Return current merged + deduped diagnostics for one file.
Diagnostics from push and pull stores are concatenated and
deduplicated by ``(severity, code, message, range)`` content
key. Empty list if the server hasn't published anything.
With ``fresh_only=True``, a store only contributes when its
version tag has caught up to the document's current version —
stale leftovers from the previous edit cycle are excluded.
This is what report paths should use: after an edit, "stale
errors" and "no errors" must not be conflated.
"""
abs_path = os.path.abspath(path)
push = self._push_diagnostics.get(abs_path) or []
pull = self._pull_diagnostics.get(abs_path) or []
return _dedupe(push, pull)
doc = self._docs.get(os.path.abspath(path))
if doc is None:
return []
if fresh_only:
return _dedupe(
doc.push if doc.fresh_push() else [],
doc.pull if doc.fresh_pull() else [],
)
return _dedupe(doc.push, doc.pull)
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+41 -10
View File
@@ -292,7 +292,10 @@ class LSPService:
if not self.enabled_for(file_path):
return
try:
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
# Outer join budget must exceed the inner wait budget or a
# slow-but-alive server gets falsely marked broken.
t = max(8.0, self._wait_timeout + 3.0)
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
self._delta_baseline[os.path.abspath(file_path)] = diags or []
except Exception as e: # noqa: BLE001
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
@@ -341,7 +344,7 @@ class LSPService:
try:
t = timeout if timeout is not None else self._wait_timeout + 2.0
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
except asyncio.TimeoutError as e:
eventlog.log_timeout(server_id, file_path)
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
@@ -353,6 +356,17 @@ class LSPService:
self._mark_broken_for_file(file_path, e)
return []
if diags is None:
# The server is alive but never produced diagnostics for the
# post-edit content within the wait budget (common for
# tsserver on large projects). Report "no data" rather than
# whatever stale state is in the stores — surfacing the
# previous edit's errors as if they were current is the
# ghost-diagnostics bug. The server is NOT marked broken:
# slow is not dead, and the next edit may well succeed.
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
return []
abs_path = os.path.abspath(file_path)
if delta:
baseline = self._delta_baseline.get(abs_path) or []
@@ -452,26 +466,43 @@ class LSPService:
return []
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
except Exception as e: # noqa: BLE001
logger.debug("snapshot open/wait failed: %s", e)
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
return list(client.diagnostics_for(file_path))
if not fresh:
# No fresh data for the pre-edit content — an empty baseline
# is safe: worst case the delta filter removes less, never
# more. Never seed the baseline from stale stores.
return []
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
"""Open + wait for FRESH diagnostics.
Returns the fresh diagnostic list, or ``None`` when the server
never produced post-change data within the wait budget. The
distinction matters: ``[]`` means "server checked the new
content, it's clean", ``None`` means "no verdict" — the caller
must not substitute stale data for either.
"""
client = await self._get_or_spawn(file_path)
if client is None:
return []
return None
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
await client.save_file(file_path)
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
fresh = await client.wait_for_diagnostics(
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
)
except Exception as e: # noqa: BLE001
logger.debug("open/wait failed for %s: %s", file_path, e)
return []
return None
self._last_used[(client.server_id, client.workspace_root)] = time.time()
return list(client.diagnostics_for(file_path))
if not fresh:
return None
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
ws, gated = resolve_workspace_for_file(file_path)
@@ -482,7 +513,7 @@ class LSPService:
client = self._clients.get((srv.server_id, ws))
if client is None:
return []
return list(client.diagnostics_for(file_path))
return list(client.diagnostics_for(file_path, fresh_only=True))
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
srv = find_server_for_file(file_path)
+14 -3
View File
@@ -319,7 +319,12 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
# Kimi — K3 ships with a 1 Mi context window (1,048,576; verified against
# models.dev and OpenRouter live metadata, matching the endpoint-scoped
# override in _endpoint_scoped_context_length). Longest-key-first substring
# matching ensures "kimi-k3" resolves to 1M while older/unknown Kimi models
# still hit the generic 256K fallback.
"kimi-k3": 1_048_576,
"kimi": 262144,
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
# so these fallbacks keep token budgeting / compression from probing down
@@ -543,7 +548,13 @@ def _is_known_provider_base_url(base_url: str) -> bool:
def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
"""Return metadata confirmed only for one provider endpoint."""
"""Return metadata confirmed only for the Kimi Coding endpoint.
Kimi Coding serves K3 under the bare slug ``k3``, but users may also
configure or select the public-facing aliases ``kimi-k3`` and
``kimi-k3-cot``. Only canonical ``https://api.kimi.com/coding`` endpoints
(legacy Moonshot keys do not serve K3) get the 1 Mi context window.
"""
normalized = _normalize_base_url(base_url)
try:
parsed = urlparse(normalized)
@@ -559,7 +570,7 @@ def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
and parsed.path.rstrip("/") in {"/coding", "/coding/v1"}
and not parsed.query
and not parsed.fragment
and model.strip().lower() == "k3"
and model.strip().lower() in {"k3", "kimi-k3", "kimi-k3-cot"}
):
return 1_048_576
return None
+33 -2
View File
@@ -15,6 +15,9 @@ and MoonshotAI/kimi-cli#1595:
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
the parent. Presence of both causes "type should be defined in anyOf
items instead of the parent schema".
3. Every object schema must carry a ``required`` array, even an empty one.
Standard JSON Schema allows omitting it; Moonshot 400s with
"required must be an array".
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
@@ -130,9 +133,32 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
else:
repaired.pop("enum")
# Rule 4: object schemas must carry a `required` array, even when empty.
if repaired.get("type") == "object":
repaired = _ensure_required_array(repaired)
return repaired
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
Standard JSON Schema lets you omit ``required`` when nothing is required;
Moonshot 400s on that ("required must be an array"). Ensure the key is a
list. When ``properties`` is known, prune ``required`` entries that don't
name a real property — defensive against dangling names, which Moonshot
also rejects. Mutates and returns ``node``.
"""
props = node.get("properties")
req = node.get("required")
if isinstance(req, list):
if isinstance(props, dict):
node["required"] = [r for r in req if r in props]
else:
node["required"] = []
return node
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a reasonable ``type`` if this schema node has none."""
node_type = node.get("type")
@@ -174,17 +200,18 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
applied. Input is not mutated.
"""
if not isinstance(parameters, dict):
return {"type": "object", "properties": {}}
return {"type": "object", "properties": {}, "required": []}
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
if not isinstance(repaired, dict):
return {"type": "object", "properties": {}}
return {"type": "object", "properties": {}, "required": []}
# Top-level must be an object schema
if repaired.get("type") != "object":
repaired["type"] = "object"
if "properties" not in repaired:
repaired["properties"] = {}
_ensure_required_array(repaired)
return repaired
@@ -232,6 +259,10 @@ def is_moonshot_model(model: str | None) -> bool:
tail = bare.rsplit("/", 1)[-1]
if tail.startswith("kimi-") or tail == "kimi":
return True
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated /
# suffixed variants like ``k3.1`` or ``k3-turbo``).
if tail == "k3" or tail.startswith(("k3.", "k3-")):
return True
# Vendor-prefixed forms commonly used on aggregators
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
return True
+13 -2
View File
@@ -805,8 +805,19 @@ PLATFORM_HINTS = {
),
"matrix": (
"You are in a Matrix room communicating with your user. "
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
"the adapter converts your Markdown to HTML for rich display. "
"The adapter converts your Markdown to HTML for rich display — bold, "
"italic, inline code, fenced code blocks, headings, bullet and "
"numbered lists, blockquotes, and links all render.\n\n"
"Do NOT use Markdown tables: many popular Matrix clients (Element X, "
"Beeper, most mobile apps) do not render HTML tables, so the cells "
"collapse into one continuous run of text. Present tabular data as "
"labeled '**Label:** value' lines or bullet lists instead.\n\n"
"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
"(- [ ] / - [x]) — they are not converted and appear as literal "
"characters.\n\n"
"LINKS: prefer [descriptive link text](url) over bare URLs. When "
"referencing something with an associated URL (events, sources, "
"people), make the name a clickable link.\n\n"
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
-47
View File
@@ -1,47 +0,0 @@
/**
* E2E boot-failure tests — verify the app shows an error overlay when the
* backend can't start.
*
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
* resolution fails with a controlled error message. The app should show the
* BootFailureOverlay with retry/repair actions.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from '@playwright/test'
import {
type DeadBackendFixture,
setupDeadBackend,
waitForBootFailure,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: DeadBackendFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('boot failure with dead backend', () => {
test('app shows error state', async () => {
// Inject a fake boot error so the backend resolution "fails" with a
// controlled error message. This is the only reliable way to trigger
// BootFailureOverlay in dev mode.
fixture = await setupDeadBackend({ fakeError: true })
await waitForBootFailure(fixture.page, 90_000)
})
test('screenshot of error state', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
})
})
-63
View File
@@ -1,63 +0,0 @@
/**
* E2E smoke tests for the dev-mode desktop app.
*
* These tests launch the Electron app from the built dist/ (not the
* packaged binary) with a real `hermes serve` backend pointed at a mock
* inference server. The full chain is exercised:
*
* electron → hermes serve (python) → mock provider → renderer
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Run from the nix devshell:
* npm exec playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('dev-mode boot with mock backend', () => {
test('window opens with Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer mounts and shows DOM content', async () => {
const page = fixture!.page
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx), but content may arrive through portals — so
// check the body for any interactive content instead.
await page.waitForSelector('body', { state: 'attached' })
// Wait for the main app shell — the composer is always present.
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: 30_000,
})
})
test('backend boots and app becomes ready', async () => {
// This is the big one — wait for the full boot chain to complete:
// electron starts → hermes serve is spawned → WS connects → config
// loaded → sessions loaded → boot overlay dismissed → composer visible.
await waitForAppReady(fixture!, 120_000)
})
test('screenshot after boot', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
})
})
-91
View File
@@ -1,91 +0,0 @@
/**
* E2E chat tests — send a message and verify a response appears.
*
* Requires the full boot chain to complete (hermes serve + mock inference
* provider). The mock server returns a canned reply, so we verify the
* response text shows up in the chat transcript.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('chat interaction with mock backend', () => {
test('send a message and receive a response', async () => {
const page = fixture!.page
// Find the composer — it's a contenteditable textbox.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
// Click to focus, then type the message character by character.
// Using `type` instead of `fill` because the composer is a
// contenteditable div with custom keydown handling that tracks
// IME composition state — `fill` bypasses the event chain.
await composer.click()
await composer.type('Hello, can you hear me?', { delay: 20 })
// Submit with Enter — the composer's keydown handler intercepts
// plain Enter (without Shift) and calls submitDraft().
await page.keyboard.press('Enter')
// Wait for the user's message to appear in the transcript.
// The message renders as an assistant-ui message in the chat view.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
undefined,
{ timeout: 15_000 },
)
// Wait for the mock response to appear. The canned reply is:
// "Hello from the mock inference server! The full boot chain is working."
// Give it a generous timeout — the inference request goes through the
// gateway → hermes serve → mock server → streaming SSE back.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
const text = body.textContent ?? ''
return text.includes('mock inference server') || text.includes('boot chain is working')
},
undefined,
{ timeout: 60_000 },
)
})
test('screenshot of chat with messages', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
})
})
-72
View File
@@ -1,72 +0,0 @@
/**
* Monkey-patch: playwright's test runner never calls tracing.start() on
* Electron's internal BrowserContext because:
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
* contexts — Electron's context is excluded.
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
* beforeAll launches the electron app.
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
* class (only on BrowserType).
*
* As a result, trace screenshots (screencast) and DOM snapshots are never
* captured for electron tests.
*
* This patch:
* 1. Patches _allContexts() to include electron contexts, so the test
* runner's didFinishTest() cleanup calls _stopTracing() → stopChunk()
* on the electron context (saving the trace chunk + merging it into
* the final trace.zip).
* 2. Manually calls tracing.start() + startChunk() after launch.
* 3. Wraps tracing.start to become startChunk after the first call,
* so the test runner's willStartTest doesn't throw "already started".
*
* Imported from playwright.config.ts so it runs before any test.
*
* Pinned dependency: this file reaches into Playwright internals (_playwright,
* _allContexts, _context) that have no public contract. @playwright/test is
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
* monkeypatch. When bumping, re-verify these private symbols still exist on
* the Electron / PlaywrightInternal classes and that tracing still merges.
*/
import { _electron as electron, type BrowserContext } from '@playwright/test'
import * as crypto from 'node:crypto'
const electronContexts = new Set<BrowserContext>()
const originalLaunch = electron.launch.bind(electron)
electron.launch = async (options: any) => {
const app = await originalLaunch(options)
const ctx = (app as any)._context as BrowserContext
electronContexts.add(ctx)
ctx.once('close', () => electronContexts.delete(ctx))
// Patch _allContexts so the test runner sees the electron context
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
const pw = (electron as any)._playwright as any
if (pw && !pw.__electronTracingPatched) {
pw.__electronTracingPatched = true
const original = pw._allContexts.bind(pw)
pw._allContexts = () => [...original(), ...electronContexts]
}
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
const traceName = crypto.randomUUID()
await ctx.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
}).catch(() => {})
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
// Wrap tracing.start to redirect to startChunk after the first call.
// The test runner's willStartTest calls tracing.start() on all contexts
// in _allContexts(). Since we already started, redirect to startChunk
// to avoid "Tracing has been already started" errors.
const tracing = ctx.tracing as any
tracing.start = async (opts: any) => {
return tracing.startChunk(opts)
}
return app
}
-674
View File
@@ -1,674 +0,0 @@
/**
* Shared E2E fixtures for the Hermes desktop Playwright suite.
*
* Two fixture modes:
*
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
* that points at it, and launches the desktop app so the full chain
* (electron → hermes serve → provider → inference → renderer) is
* exercised with a real backend but a fake LLM.
*
* 2. `noProvider` — launches the app with an empty config (no provider
* configured). The onboarding overlay should appear. Used to test the
* first-run flow without real credentials.
*
* Both modes launch the *dev* Electron app (`electron .` against the built
* `dist/`), not the packaged binary. This avoids the multi-minute
* `electron-builder --dir` step and matches `hermes desktop --source`. The
* packaged-binary path is already covered by `launch.spec.ts`.
*
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
const CREDENTIAL_SUFFIXES: string[] = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN',
]
const CREDENTIAL_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL',
])
function isCredentialEnvVar(name: string): boolean {
if (CREDENTIAL_NAMES.has(name)) {
return true
}
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
}
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
const clean: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (!value) {
continue
}
if (isCredentialEnvVar(key)) {
continue
}
clean[key] = value
}
return clean
}
// ─── Sandbox creation ──────────────────────────────────────────────────
export interface Sandbox {
root: string
hermesHome: string
userDataDir: string
cleanup: () => void
}
function createSandbox(prefix: string): Sandbox {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
// Write a fixed window-state.json so the Electron window opens at a
// consistent size — helps with visual regression screenshots. The
// exact size is also enforced right before each screenshot (see
// expectVisualSnapshot in visual-snapshot.ts) because window managers
// may resize after launch.
fs.writeFileSync(
path.join(userDataDir, 'window-state.json'),
JSON.stringify(
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
null,
2,
),
'utf8',
)
return {
root,
hermesHome,
userDataDir,
cleanup: () => {
try {
fs.rmSync(root, { recursive: true, force: true })
} catch {
// best-effort
}
},
}
}
// ─── Config writing ─────────────────────────────────────────────────────
/**
* Write a config.yaml that pre-configures a mock provider pointing at the
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*/
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`
fs.writeFileSync(configPath, config, 'utf8')
}
/**
* Write a minimal .env with the mock API key. The key_env in config.yaml
* references MOCK_API_KEY, so the backend resolves credentials from here.
*/
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
const envPath = path.join(hermesHome, '.env')
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
}
/**
* Write an empty config (no providers). The desktop app should show the
* onboarding overlay because no inference provider is configured.
*/
function writeEmptyConfig(hermesHome: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
}
// ─── Env building ──────────────────────────────────────────────────────
/**
* Build the environment for the Electron app process.
*
* Key env vars:
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
* (we want the dev checkout at REPO_ROOT)
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
*/
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
const clean = stripCredentials(process.env)
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
// headless/CI context — without it the zygote may fail to initialize.
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
}
// DISPLAY — needed for Electron to open a window.
if (!clean.DISPLAY && process.env.DISPLAY) {
clean.DISPLAY = process.env.DISPLAY
}
return {
...clean,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
// Clear dev-server override — we want the built dist/, not a vite server.
// The dev-server check in main.ts looks for this env var; if it's set,
// it loads from the vite URL instead of the local file.
...extra,
}
}
// ─── Electron launch ────────────────────────────────────────────────────
/**
* Verify that the desktop app has been built (dist/ exists). Playwright
* tests can't run without it — the Electron main process loads
* dist/electron-main.mjs and the renderer loads dist/index.html.
*/
function assertDistBuilt(): void {
const distDir = path.join(DESKTOP_ROOT, 'dist')
const electronMain = path.join(distDir, 'electron-main.mjs')
const indexHtml = path.join(distDir, 'index.html')
if (!fs.existsSync(electronMain)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
if (!fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${indexHtml}`,
)
}
}
/**
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
* As a fallback, use the node_modules/.bin/electron from the desktop package.
*/
function findElectron(): string {
// In dev mode, we use the `electron` binary directly (not the packaged app).
// The dev:electron script in package.json does exactly this: `electron .`
// after building. We replicate that here.
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(localElectron)) {
return localElectron
}
// Fall back to PATH
const result = spawnSync('which', ['electron'], {
encoding: 'utf8',
})
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
throw new Error(
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
)
}
/**
* Launch the desktop app in dev mode.
*
* @param sandbox - isolated HERMES_HOME + userData
* @param env - the process environment (already has HERMES_HOME etc.)
* @returns the ElectronApplication + first Page
*/
async function launchDesktop(
env: Record<string, string>,
): Promise<{ app: ElectronApplication; page: Page }> {
assertDistBuilt()
const electronBin = findElectron()
// `electron .` loads from the package.json `main` field
// (dist/electron-main.mjs after build).
const app = await _electron.launch({
executablePath: electronBin,
args: [
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
'--disable-gpu',
'--no-sandbox',
],
env,
cwd: DESKTOP_ROOT,
})
const page = await app.firstWindow()
return { app, page }
}
// ─── Public fixtures ────────────────────────────────────────────────────
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mockUrl: string
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
* 2. Create a sandbox with config.yaml pointing at it
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export async function setupMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
export interface NoProviderFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the app with no provider configured. The onboarding overlay should
* appear because there's no inference provider in config.yaml.
*/
export async function setupNoProvider(): Promise<NoProviderFixture> {
const sandbox = createSandbox('noprovider')
writeEmptyConfig(sandbox.hermesHome)
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
export interface DeadBackendFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
export interface DeadBackendOptions {
/**
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
* so the backend resolution itself "fails" with a controlled error message.
* This is the only reliable way to trigger BootFailureOverlay in dev mode
* (the real backend always resolves via SOURCE_REPO_ROOT).
*/
fakeError?: boolean
}
/**
* Launch the app with a provider pointing at a dead endpoint (port 1, which
* nothing listens on). By default the backend still boots (`hermes serve`
* starts fine — the dead endpoint only matters at chat time). Pass
* `{ fakeError: true }` to inject a fake boot failure, triggering the
* BootFailureOverlay.
*/
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
const sandbox = createSandbox('dead')
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
fs.writeFileSync(
configPath,
`# Auto-generated by E2E test fixtures — dead provider
model:
default: mock-model
provider: mock
providers:
mock:
api: http://127.0.0.1:1/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
writeEnvFile(sandbox.hermesHome)
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Packaged-binary fixture ───────────────────────────────────────────
/**
* Resolve the packaged Electron binary path, per-platform, matching
* electron-builder's output layout under release/.
*/
function resolvePackagedBinaryPath(): string {
if (process.platform === 'win32') {
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
if (process.platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
}
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
export function packagedBinaryExists(): boolean {
return fs.existsSync(PACKAGED_BINARY_PATH)
}
export interface PackagedAppFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the *packaged* Electron binary (from `npm run pack` →
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
* progress without spawning a real Hermes backend.
*
* Uses the same sandbox isolation (credential stripping, isolated
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
*
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
*/
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
if (!packagedBinaryExists()) {
throw new Error(
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
}
const sandbox = createSandbox('packaged')
// Build the sandbox env using the shared helpers, then add the
// packaged-binary-specific overrides.
const env = buildAppEnv(sandbox, {
// Fake boot: simulates progress steps without spawning the real backend.
HERMES_DESKTOP_BOOT_FAKE: '1',
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
})
// Clear dev-server + hermes-root overrides — the packaged binary
// should use its own bundled renderer, not the dev checkout.
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
const app = await _electron.launch({
executablePath: PACKAGED_BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox'],
env,
})
const page = await app.firstWindow()
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Wait helpers ──────────────────────────────────────────────────────
/**
* Wait for the desktop app to finish booting and show the main chat UI.
*
* The boot overlay disappears when `completeDesktopBoot()` fires in the
* renderer — at that point the gateway is open, config is loaded, and
* sessions are loaded. We detect this by waiting for the boot/connecting
* overlay to become invisible and the main app shell to be present.
*
* Two things must both be true before we return:
* 1. The composer (chat input) is visible — it's disabled until the
* gateway is open.
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
* boot-failure) covers the viewport center. The composer can be
* "visible" in Playwright's eyes (non-zero bounding box, not
* display:none) even when a z-1300+ overlay is painted on top of it,
* so checking the composer alone catches the app mid-boot at ~92%
* with the loading bar still showing.
*/
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
const { page, app } = fixture
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: timeoutMs,
})
// Now poll until no full-screen overlay covers the viewport center.
// elementFromPoint returns the topmost element at a point — if it's part
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
// app isn't ready yet.
await page.waitForFunction(
() => {
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
if (!el) {
return false
}
// Walk up to the nearest positioned ancestor — overlays are
// `position: fixed; inset: 0`. If the hit element or an ancestor
// is a full-viewport fixed overlay, we're still covered.
let node: Element | null = el
while (node) {
const cs = window.getComputedStyle(node)
if (cs.position === 'fixed') {
const rect = node.getBoundingClientRect()
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
return false
}
}
node = node.parentElement
}
return true
},
undefined,
{ timeout: timeoutMs },
)
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
// and the window stays hidden even though the DOM is rendered. The main
// process has a TEST_WORKER_INDEX-gated fallback that force-shows the
// window, but the DOM can be ready before that fires. Poll until the
// window is actually visible so interactions (click, screenshot) don't
// hit a hidden surface.
if (app) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const visible = await app.evaluate(({ BrowserWindow }) => {
const w = BrowserWindow.getAllWindows()[0]
return w ? w.isVisible() : false
}).catch(() => false)
if (visible) {break}
await page.waitForTimeout(500)
}
}
}
/**
* Wait for the onboarding overlay to appear (no provider configured).
*/
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
// The onboarding overlay contains a heading with "Choose your provider"
// or similar text. We look for any text that indicates the picker.
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
return (
text.includes('provider') ||
text.includes('Provider') ||
text.includes('Choose') ||
text.includes('API key') ||
text.includes('Sign in')
)
},
undefined,
{ timeout: timeoutMs },
)
}
/**
* Wait for the boot failure overlay to appear.
*/
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
await page.waitForFunction(
() => {
// Boot failure is terminal: the backend gave up. The renderer shows
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
// falls back to the onboarding picker (z-1300) as a recovery path.
// We wait for the failure dialog itself — the Preparing component may
// still paint its progress bar (recolored red) underneath the overlay,
// which is harmless.
const text = document.body.textContent ?? ''
// BootFailureOverlay buttons.
const hasFailureUI =
text.includes('Retry') ||
text.includes('Repair') ||
text.includes('Use local gateway') ||
text.includes('Connection settings')
// The error toast / notification that fires on failDesktopBoot().
const hasErrorToast = text.includes('Desktop boot failed')
return hasFailureUI || hasErrorToast
},
undefined,
{ timeout: timeoutMs },
)
}
@@ -1,88 +0,0 @@
import { expect, test } from '@playwright/test'
import {
PACKAGED_BINARY_PATH,
type PackagedAppFixture,
packagedBinaryExists,
setupPackagedApp,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
/**
* E2E smoke tests for the packaged Hermes desktop app.
*
* Launches the real packaged Electron binary (produced by `npm run pack` →
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
*
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
*/
let fixture: PackagedAppFixture | null = null
test.beforeAll(async () => {
test.skip(
!packagedBinaryExists(),
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
fixture = await setupPackagedApp()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('window opens with the Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
const page = fixture!.page
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
const page = fixture!.page
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
// Error path: boot failure overlay renders an error message.
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
return true
}
// Success path: overlay disappears and the app renders. If there's
// no "boot" / "starting" / "installing" text visible, boot has
// completed (either to the main UI or to onboarding).
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
const lower = text.toLowerCase()
return !bootIndicators.some((word) => lower.includes(word))
},
undefined,
{ timeout: 60_000 },
)
})
test('can capture a screenshot for the CI artifact', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
// Visual snapshot — won't fail on diff, just logs + generates diff image
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
})
@@ -1,87 +0,0 @@
/**
* E2E tests asserting the mock backend gets the app past the setup/onboarding
* screen.
*
* The mock backend fixture writes a config.yaml with a pre-configured mock
* provider pointing at a mock inference server. When the app boots, the
* runtime readiness check should detect the working provider and dismiss the
* onboarding overlay — landing straight on the chat UI without ever showing
* the "Let's get you setup with Hermes Agent" screen.
*
* If these tests fail, the mock backend config isn't getting the app past
* onboarding — the chat interaction tests (chat.spec.ts) will also fail
* because the composer is blocked by the setup overlay.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('mock backend gets past setup screen', () => {
test('onboarding overlay is not shown', async () => {
const page = fixture!.page
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
// when the runtime check fails to find a working provider. With the mock
// backend configured, the runtime check should pass and the overlay
// returns null — this text should NOT be present in the DOM.
await page.waitForFunction(
() => {
const text = document.body.textContent ?? ''
return !text.includes("Let's get you setup")
},
undefined,
{ timeout: 30_000 },
)
})
test('chat composer is visible', async () => {
const page = fixture!.page
// The composer (contenteditable div) should be visible and not blocked
// by the onboarding overlay. If the first test passed, the overlay is
// gone and the composer is the primary interactive surface.
const composer = page.locator('[contenteditable="true"]').first()
await expect(composer).toBeVisible()
})
test('can type into the composer', async () => {
const page = fixture!.page
// If the setup overlay is truly gone, the composer accepts input.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('hello mock backend', { delay: 20 })
// Verify the typed text appears in the DOM.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('hello mock backend'),
undefined,
{ timeout: 10_000 },
)
})
test('screenshot shows chat UI without setup screen', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
})
})
-203
View File
@@ -1,203 +0,0 @@
/**
* Minimal OpenAI-compatible mock inference server for E2E tests.
*
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
* provider, list models, and stream a canned chat completion back to the
* desktop app — without any real LLM.
*
* Endpoints:
* GET /v1/models → { data: [{ id, ... }] }
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
*
* The canned response is a short, deterministic assistant message. Tool-call
* requests are not simulated — the E2E tests only need the chat surface to
* prove the full boot → gateway → inference → renderer chain works.
*/
import http from 'node:http'
/** A canned assistant reply used for every chat completion request. */
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, and `close()`.
*/
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
// CORS headers — the Electron renderer doesn't need them, but they
// don't hurt and make the server usable from a browser context too.
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
// GET /v1/models — return a single fake model.
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [
{
id: 'mock-model',
object: 'model',
created: 0,
owned_by: 'mock',
},
],
}),
)
return
}
// POST /v1/chat/completions — return a canned response.
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', () => {
let parsed: any = {}
try {
parsed = JSON.parse(body)
} catch {
// malformed JSON — treat as non-streaming with defaults
}
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// Send the content in a few chunks to simulate streaming.
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
// Final chunk with finish_reason
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: { content: word },
finish_reason: null,
},
],
})}\n\n`,
)
i++
// Small delay between chunks to simulate real streaming.
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
// Non-streaming response
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
)
}
})
req.on('error', () => {
res.writeHead(400)
res.end('Bad request')
})
return
}
// Fallback — 404 for anything else
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
const port = addr.port
const url = `http://127.0.0.1:${port}`
resolve({
port,
url,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
if (err) {
rejectClose(err)
} else {
resolveClose()
}
})
}),
})
})
})
}
-76
View File
@@ -1,76 +0,0 @@
/**
* E2E onboarding tests — verify the provider picker appears when no
* inference provider is configured.
*
* Launches the app with an empty config.yaml (no providers). The renderer
* should detect the unconfigured state and show the DesktopOnboardingOverlay
* with provider options / API key form.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type NoProviderFixture,
setupNoProvider,
waitForOnboarding,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: NoProviderFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('onboarding with no provider configured', () => {
test('onboarding overlay appears on first boot', async () => {
fixture = await setupNoProvider()
// The app should boot (hermes serve starts fine even without a provider),
// but the renderer should show the onboarding overlay because no
// provider is configured.
await waitForOnboarding(fixture.page, 90_000)
})
test('onboarding shows provider options or API key form', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
const page = fixture.page
// The onboarding overlay should contain provider-related text.
// It might show OAuth providers, an API key form, or a "choose later"
// link. Verify at least one of these is visible.
const rootText = await page.evaluate(() => {
const root = document.getElementById('root')
return root?.textContent ?? ''
})
const hasProviderText =
rootText.includes('provider') ||
rootText.includes('Provider') ||
rootText.includes('API key') ||
rootText.includes('Sign in') ||
rootText.includes('OpenRouter') ||
rootText.includes('OpenAI')
expect(hasProviderText).toBe(true)
})
test('screenshot of onboarding overlay', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
})
})
-150
View File
@@ -1,150 +0,0 @@
/**
* Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are
* reported without failing the test suite.
*
* On CI, the JSON reporter + post-test script parse the results and post a
* summary to the GitHub Actions step output, and diff images are uploaded
* as artifacts. This keeps visual regressions visible without gating PRs
* on pixel-perfect matches.
*
* The actual screenshot is always written to the test output dir so CI
* artifacts include every screenshot — not just the ones that diffed.
* When it differs, this helper also writes expected and diff images:
* <name>-actual.png, <name>-expected.png, <name>-diff.png
*/
import fs from 'node:fs'
import path from 'node:path'
import { type ElectronApplication, type Page, test } from '@playwright/test'
/** Fixed window dimensions for visual regression screenshots. */
export const VISUAL_WINDOW_WIDTH = 1220
export const VISUAL_WINDOW_HEIGHT = 800
export interface VisualSnapshotOptions {
/** Snapshot name — defaults to the test title. */
name?: string
/** Full page screenshot vs. viewport-only (default). */
fullPage?: boolean
/** Timeout in ms. */
timeout?: number
/** The Electron app handle — used to size and decode screenshots. */
app: ElectronApplication
}
/**
* Force the Electron window to a fixed size so screenshots are comparable
* across runs and CI environments. Window managers (Hyprland, etc.) may
* auto-tile or resize windows after launch; calling this right before the
* screenshot ensures the viewport is always the expected size.
*/
async function forceFixedSize(app: ElectronApplication): Promise<void> {
await app.evaluate(({ BrowserWindow }, { width, height }) => {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
win.unmaximize()
// setMinimumSize must be ≤ the target, otherwise setSize is clamped.
win.setMinimumSize(width, height)
win.setSize(width, height, false)
win.setBounds({ x: 0, y: 0, width, height })
}
}, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
}
/**
* Take a screenshot and compare it against the baseline.
*
* If the baseline doesn't exist yet (first run), Playwright creates it.
* If it differs, the test logs a soft warning but does NOT fail — the diff
* images are still generated for CI to surface.
*/
export async function expectVisualSnapshot(
page: Page,
options: VisualSnapshotOptions,
): Promise<void> {
const { name, fullPage = false, timeout = 30_000, app } = options
// Force the window to a fixed size right before the screenshot so it's
// always comparable, regardless of WM resizing during the test.
await forceFixedSize(app)
// Give the renderer a moment to relayout after the resize.
await page.waitForTimeout(500)
// Playwright appends a platform suffix (e.g. "-linux") and requires
// a .png extension on the name argument. Auto-append it if missing.
const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
const info = test.info()
const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
fs.writeFileSync(baselinePath, actual)
// Also write to the output dir so CI artifacts include the screenshot.
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
console.log(`[visual-baseline] updated ${baselinePath}`)
return
}
if (!fs.existsSync(baselinePath)) {
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
return
}
const expected = fs.readFileSync(baselinePath)
const comparison = await app.evaluate(
({ nativeImage }, images) => {
const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
const actualSize = actualImage.getSize()
const expectedSize = expectedImage.getSize()
if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
return { mismatchRatio: 1, diff: images.actual }
}
const actualPixels = actualImage.toBitmap()
const expectedPixels = expectedImage.toBitmap()
const diffPixels = Buffer.alloc(actualPixels.length)
let mismatched = 0
for (let i = 0; i < actualPixels.length; i += 4) {
const different =
Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
if (different) {
mismatched++
diffPixels[i + 2] = 255
}
diffPixels[i + 3] = 255
}
return {
mismatchRatio: mismatched / (actualPixels.length / 4),
diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
}
},
{ actual: actual.toString('base64'), expected: expected.toString('base64') },
)
// Always write the actual screenshot to the output dir so CI artifacts
// include every screenshot — not just the ones that diffed.
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
if (comparison.mismatchRatio <= 0.01) {
return
}
fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
console.log(
`[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
)
}
-19
View File
@@ -542,7 +542,6 @@ const DESKTOP_LOG_BACKUP_COUNT = 3
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
const BOOT_FAKE_STEP_MS = (() => {
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
@@ -7000,16 +6999,6 @@ async function startHermes() {
throw backendStartFailure
}
// E2E: simulate a boot failure without breaking the real backend. The boot
// progresses a few steps, then fails with the given error message.
if (BOOT_FAKE_ERROR) {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const error = new Error(BOOT_FAKE_ERROR) as any
error.isBootstrapFailure = true
bootstrapFailure = error
throw error
}
const existingConnectionPromise = backendConnectionState.getPromise()
if (existingConnectionPromise) {
@@ -7619,14 +7608,6 @@ function createWindow() {
}
})
// Under Playright testing, instantly show the window.
// `ready-to-show` doesn't fire in some testing envs.
if (process.env.TEST_WORKER_INDEX !== undefined) {
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
mainWindow.show()
}
}
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
+2 -7
View File
@@ -13,7 +13,6 @@
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node scripts/dev-mock.mjs",
"dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
@@ -41,7 +40,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
@@ -50,10 +49,7 @@
"test:desktop:platforms": "vitest run --project electron",
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
"test:e2e": "playwright test e2e/",
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
},
"dependencies": {
"@assistant-ui/react": "^0.14.23",
@@ -128,7 +124,6 @@
"devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4",
"@playwright/test": "=1.58.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
-63
View File
@@ -1,63 +0,0 @@
import './e2e/fix-electron-tracing'
import { defineConfig, type ReporterDescription } from '@playwright/test'
/**
* Visual regression testing config.
*
* Screenshots are compared against baselines. On `main`, baselines are
* generated with `--update-snapshots` and cached. On PRs, the cached
* baselines are restored and screenshots are compared — but tests DON'T
* fail on visual diffs (see `expectVisualSnapshot` in visual-snapshot.ts).
* Instead, diffs are surfaced in the CI step summary and uploaded as
* artifacts for human review.
*
* To update baselines after an intentional UI change:
* npx playwright test --update-snapshots
*/
const reporters: ReporterDescription[] = [
['list'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
]
if (process.env.CI) {
reporters.push(['json', { outputFile: 'playwright-report/results.json' }])
}
export default defineConfig({
/* Test files live under e2e/ so they never collide with the vitest suite
* under src/ or the node:test files under electron/. */
testDir: './e2e',
/* The desktop app can take a while to bootstrap on cold CI runners — 90 s
* per test gives us headroom without masking real hangs. */
timeout: 90_000,
retries: process.env.CI ? 1 : 0,
/* Each test gets its own worker so the Electron process is fully isolated. */
fullyParallel: false,
reporter: reporters,
use: {
screenshot: 'on',
trace: { mode: 'on', screenshots: true, snapshots: true, sources: true },
// Emulate prefers-reduced-motion: reduce so all CSS transitions and
// animations resolve instantly. This prevents boot/connecting overlays
// from being mid-fade when a screenshot fires, and skips JS-driven exit
// choreography in components that check matchMedia (onboarding, connecting
// overlay, DecodeText). Without this, screenshots capture the loading bar
// or overlay at a transient opacity because the text-content check fires
// before the visual transition finishes.
contextOptions: {
reducedMotion: 'reduce',
},
},
expect: {
toHaveScreenshot: {
// 1% of pixels may differ — absorbs sub-pixel font rendering variance
// between local and CI environments.
maxDiffPixelRatio: 0.01,
animations: 'disabled',
caret: 'hide',
// Per-channel threshold for "close enough" — anti-aliasing differences.
threshold: 0.2,
},
},
})
-237
View File
@@ -1,237 +0,0 @@
#!/usr/bin/env node
/**
* Launch the desktop app with a mock inference provider — no real API
* keys needed. Starts a local OpenAI-compatible server that returns a
* canned reply, writes an isolated config.yaml + .env, and launches the
* built Electron app against them.
*
* This reuses the same mock-server and config format as the E2E fixtures
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
* test the same chain.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*
* Usage:
* node scripts/dev-mock.mjs
* npm run dev:mock
*
* The mock server listens on an ephemeral port and replies to every
* chat completion with:
* "Hello from the mock inference server! The full boot chain is working."
*/
import http from 'node:http'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
// ── Canned reply ───────────────────────────────────────────────────────
const CANNED_REPLY =
'Hello from the mock inference server! The full boot chain is working.'
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
function startMockServer() {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
}),
)
return
}
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk) => { body += chunk.toString() })
req.on('end', () => {
let parsed = {}
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: { content: word }, finish_reason: null }],
})}\n\n`,
)
i++
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion', object: 'chat.completion',
created: 0, model,
choices: [{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
})
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
})
})
}
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
function createSandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
}
function writeMockConfig(hermesHome, mockUrl) {
fs.writeFileSync(
path.join(hermesHome, 'config.yaml'),
`# Auto-generated by dev-mock.mjs
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
}
// ── Electron launch ────────────────────────────────────────────────────
function findElectron() {
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(local)) return local
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
}
function assertDistBuilt() {
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
assertDistBuilt()
console.log('Starting mock inference server...')
const mock = await startMockServer()
console.log(` Mock server: ${mock.url}`)
const sandbox = createSandbox()
writeMockConfig(sandbox.hermesHome, mock.url)
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
const electronBin = findElectron()
const env = {
...process.env,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
}
console.log('Launching Electron...')
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
env,
cwd: DESKTOP_ROOT,
stdio: 'inherit',
})
child.on('exit', (code) => {
mock.close()
sandbox.cleanup()
process.exit(code ?? 0)
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -409,6 +409,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (sessionId) {
flushQueuedDeltas(sessionId)
const text = coerceGatewayText(payload?.text)
if (text) {
finalizeInterimAssistantMessage(sessionId, text)
}
@@ -379,6 +379,7 @@ export function useMessageStream({
}
const authoritativeText = renderMediaTags(text).trim()
if (!authoritativeText) {
return state
}
@@ -386,29 +387,30 @@ export function useMessageStream({
const streamId = state.streamId
const replaceTextPart = (parts: ChatMessagePart[]) => {
const visibleText = stripGeneratedImageEchoes(
authoritativeText, generatedImageEchoSources(parts)
).trim()
const visibleText = stripGeneratedImageEchoes(authoritativeText, generatedImageEchoSources(parts)).trim()
return mergeFinalAssistantText(parts, visibleText)
}
let nextMessages = state.messages
if (streamId && nextMessages.some(m => m.id === streamId)) {
// Finalize the existing streaming bubble in place
nextMessages = nextMessages.map(m =>
m.id === streamId
? { ...m, parts: replaceTextPart(m.parts), pending: false }
: m
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m
)
} else {
// No streaming bubble — create a standalone interim message
nextMessages = [...nextMessages, {
id: `assistant-interim-${Date.now()}`,
role: 'assistant' as const,
parts: [assistantTextPart(authoritativeText)],
pending: false,
branchGroupId: state.pendingBranchGroup ?? undefined
}]
nextMessages = [
...nextMessages,
{
id: `assistant-interim-${Date.now()}`,
role: 'assistant' as const,
parts: [assistantTextPart(authoritativeText)],
pending: false,
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
}
return {
@@ -451,6 +453,7 @@ export function useMessageStream({
const replaceTextPart = (parts: ChatMessagePart[]) => {
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
return mergeFinalAssistantText(parts, visibleFinalText)
}
@@ -4,9 +4,9 @@ import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { chatMessageText } from '@/lib/chat-messages'
import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearSessionTodos } from '@/store/todos'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
@@ -55,10 +55,13 @@ async function mountStream() {
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
const interim = (text: string) =>
act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
const complete = (text: string) =>
act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' }))
const completePreviewed = (text: string) =>
act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' }))
@@ -69,11 +72,13 @@ function getState(): ClientSessionState {
function assistantText(): string {
const state = getState()
const last = [...state.messages].reverse().find(m => m.role === 'assistant' && !m.hidden)
return last ? chatMessageText(last) : ''
}
function assistantMessages(): string[] {
const state = getState()
return state.messages
.filter(m => m.role === 'assistant' && !m.hidden)
.map(m => chatMessageText(m))
@@ -222,7 +227,9 @@ describe('useMessageStream interim text sealing', () => {
// Empty text
await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
// Undefined text
await act(() => handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent))
await act(() =>
handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
)
// Turn continues without finalizing or throwing
expect(getState().busy).toBe(true)
@@ -36,7 +36,7 @@ describe('ResponseLoadingIndicator timer', () => {
const sessionA = renderIndicator()
act(() => vi.advanceTimersByTime(5_000))
expect(screen.getByText('5s')).toBeTruthy()
expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
sessionA.unmount()
$activeSessionId.set('session-b')
@@ -44,13 +44,13 @@ describe('ResponseLoadingIndicator timer', () => {
const sessionB = renderIndicator()
act(() => vi.advanceTimersByTime(3_000))
expect(screen.getByText('3s')).toBeTruthy()
expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
sessionB.unmount()
$activeSessionId.set('session-a')
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
renderIndicator()
expect(screen.getByText('8s')).toBeTruthy()
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
})
@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'
import { formatElapsed } from './activity-timer'
import { StableText } from './stable-text'
interface ActivityTimerTextProps {
seconds: number
@@ -9,16 +10,16 @@ interface ActivityTimerTextProps {
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
return (
<span
<StableText
className={cn(
// Tinted with --dt-midground (very low alpha) so the timer reads
// as part of the same "live signal" cluster as the dither block /
// arc-border / working-session dot, instead of being neutral chrome.
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-midground/55 tabular-nums',
'shrink-0 text-[0.56rem] leading-none tracking-[0.02em] text-midground/55',
className
)}
>
{formatElapsed(seconds)}
</span>
</StableText>
)
}
@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils'
interface StableTextProps {
children: string
className?: string
}
/**
* Renders text as a row of 1ch-wide cells so individual characters can't
* shift the layout as they change (e.g. digits in a ticking timer).
* Works with any proportional font — no need for font-mono.
*/
export function StableText({ children, className }: StableTextProps) {
return (
<span className={cn('inline-flex', className)}>
{children.split('').map((char, i) => (
<span className="inline-block w-[1ch] text-center" key={i}>
{char}
</span>
))}
</span>
)
}
@@ -35,20 +35,11 @@ function forcedPreview(): boolean {
}
}
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
}
export function GatewayConnectingOverlay() {
const gatewayState = useStore($gatewayState)
const boot = useStore($desktopBoot)
const gatewaySwitching = useStore($gatewaySwitching)
const [previewing] = useState(forcedPreview)
const reduce = prefersReducedMotion()
// Under reduced motion, skip the multi-phase exit choreography (text-out →
// hold → overlay fade) and jump straight to gone so the overlay unmounts
// the instant the gateway opens. E2E screenshots rely on this to avoid
// catching the overlay mid-fade.
const [phase, setPhase] = useState<Phase>('live')
// Once cold boot has completed once, never resurrect the fullscreen overlay
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
@@ -90,13 +81,9 @@ export function GatewayConnectingOverlay() {
}
if (gatewayState === 'open' && shownRef.current) {
// Under reduced motion, skip the multi-phase exit choreography
// (text-out → hold → overlay fade) and jump straight to gone so the
// overlay unmounts the instant the gateway opens. E2E screenshots
// rely on this to avoid catching the overlay mid-fade.
setPhase(reduce ? 'gone' : 'text-out')
setPhase('text-out')
}
}, [phase, previewing, gatewayState, reduce])
}, [phase, previewing, gatewayState])
// Advance the exit choreography: text-out -> overlay-out -> gone.
useEffect(() => {
@@ -23,10 +23,6 @@ export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
const TICK_MS = 45
const HOLD_TICKS = 16
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
}
function scrambled(tail: string, resolvedCount: number): string {
return Array.from(tail, (ch, i) =>
ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0]
@@ -66,15 +62,6 @@ export function DecodeText({
return
}
// Under reduced motion, skip the scramble interval and render the fully
// resolved text immediately. The cursor blink (CSS animation) is also
// killed by the blanket reduced-motion CSS rule.
if (prefersReducedMotion()) {
setTail(tailText)
return
}
let resolved = 0
let hold = 0
+5 -9
View File
@@ -804,10 +804,7 @@ describe('mergeFinalAssistantText', () => {
})
it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => {
const parts = [
reasoningPart('Let me check the files.'),
{ type: 'text' as const, text: 'streamed' }
]
const parts = [reasoningPart('Let me check the files.'), { type: 'text' as const, text: 'streamed' }]
const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.')
@@ -819,7 +816,9 @@ describe('mergeFinalAssistantText', () => {
// #61447: a short final ("Done.") must NOT swallow a longer reasoning block
// that merely starts with it.
const parts = [
reasoningPart('Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'),
reasoningPart(
'Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'
),
{ type: 'text' as const, text: 'streamed' }
]
@@ -842,10 +841,7 @@ describe('mergeFinalAssistantText', () => {
})
it('handles empty final text', () => {
const parts = [
{ type: 'text' as const, text: 'streamed' },
reasoningPart('some reasoning')
]
const parts = [{ type: 'text' as const, text: 'streamed' }, reasoningPart('some reasoning')]
const result = mergeFinalAssistantText(parts, '')
+1 -4
View File
@@ -152,10 +152,7 @@ const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
* - Keeps all other part types (tool-call, image, etc.).
* - Appends the final text as a new text part.
*/
export function mergeFinalAssistantText(
parts: ChatMessagePart[],
finalText: string
): ChatMessagePart[] {
export function mergeFinalAssistantText(parts: ChatMessagePart[], finalText: string): ChatMessagePart[] {
const dedupeReference = normalizeWs(finalText)
const kept = parts.filter(part => {
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { StableText } from '@/components/chat/stable-text'
import { compactNumber } from '@/lib/format'
import type { UsageStats } from '@/types/hermes'
@@ -72,5 +73,9 @@ export function LiveDuration({ since }: { since: number | null | undefined }) {
return () => window.clearInterval(timer)
}, [since])
return since ? formatDuration(now - since) : null
if (!since) {
return null
}
return <StableText>{formatDuration(now - since)}</StableText>
}
+2 -6
View File
@@ -33,16 +33,12 @@ export function applyDesktopBootProgress(progress: DesktopBootProgress) {
const nextProgress = clampProgress(progress.progress)
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
// Don't let a late progress event (error: null) clobber a previously-set
// boot failure — failDesktopBoot is terminal for this boot cycle.
const error = progress.error ?? (current.running ? null : current.error)
$desktopBoot.set({
...current,
...progress,
error,
error: progress.error ?? null,
progress: mergedProgress,
visible: progress.running || mergedProgress < 100 || Boolean(error)
visible: progress.running || mergedProgress < 100 || Boolean(progress.error)
})
}
+2 -3
View File
@@ -1,4 +1,3 @@
import type { ConnectionState } from '@hermes/shared'
import { atom, computed } from 'nanostores'
import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
@@ -214,7 +213,7 @@ export function mergeSessionPage(
}
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom<ConnectionState>('idle')
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
export const $sessionsTotal = atom<number>(0)
// Cron-job sessions (source === 'cron') are fetched as their own list so the
@@ -320,7 +319,7 @@ export const $modelPickerOpen = atom(false)
export const $sessionPickerOpen = atom(false)
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
export const setGatewayState = (next: Updater<ConnectionState>) => updateAtom($gatewayState, next)
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
-16
View File
@@ -5,22 +5,6 @@
@import '@vscode/codicons/dist/codicon.css';
@custom-variant dark (&:is(.dark *));
/* Blanket reduced-motion override: kill ALL CSS animations and transitions
when the user (or the E2E test harness via prefers-reduced-motion) requests
it. Per-component @media rules below handle specific cases; this catches
everything else so overlays and loading bars resolve instantly instead of
being caught mid-fade by a screenshot. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Sidebar sections: tall viewports give each its own scroller; compact ones
(this variant) flatten everything into one shared scroll. See ChatSidebar. */
@custom-variant compact (@media (max-height: 768px));
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node", "@playwright/test"],
"composite": true
},
"include": ["e2e", "playwright.config.ts"],
"exclude": ["src", "electron"]
}
-1
View File
@@ -23,6 +23,5 @@
}
},
"include": ["src", "../shared/src"],
"exclude": ["e2e", "electron", "playwright.config.ts"],
"references": [{ "path": "./tsconfig.electron.json" }]
}
@@ -0,0 +1 @@
momomojo
+1
View File
@@ -0,0 +1 @@
ajzrva-sys
@@ -0,0 +1 @@
Almurat123
@@ -0,0 +1 @@
geoffreybutler94
+1
View File
@@ -0,0 +1 @@
gigakun3030
+1
View File
@@ -0,0 +1 @@
okisdev
@@ -0,0 +1 @@
githubespresso407
+1
View File
@@ -0,0 +1 @@
M1racleShih
@@ -0,0 +1 @@
DatTheMaster
@@ -0,0 +1 @@
datachainsystems
@@ -0,0 +1 @@
frizikk
@@ -0,0 +1 @@
JasonFang1993
+1
View File
@@ -0,0 +1 @@
F4TB0Yz
@@ -0,0 +1 @@
kuangmi-bit
@@ -0,0 +1 @@
ly-wang19
@@ -0,0 +1 @@
marcolivierlavoie
+1
View File
@@ -0,0 +1 @@
MarkVLK
@@ -0,0 +1 @@
Punyko8
+1
View File
@@ -0,0 +1 @@
RKelln
+1
View File
@@ -0,0 +1 @@
sjiangtao2024
@@ -0,0 +1 @@
TurgutKural
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

-126
View File
@@ -1,126 +0,0 @@
#Requires AutoHotkey v2.0
#SingleInstance Force
logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log"
Log(text) {
msg := Format("[autohotkey] {}`n", text)
ToolTip(text)
FileAppend(msg, '*')
FileAppend(msg, logPath)
}
OnError(LogError)
LogError(err, mode) {
Log(Format("Unhandled error: {}", err.Message))
ExitApp(1)
return -1 ; suppress the standard error dialog
}
SetWorkingDir(A_ScriptDir)
CoordMode("Pixel", "Screen")
CoordMode("Mouse", "Screen")
ClickWithMarker(x, y, button := "Left") {
Click(x, y, button)
Sleep(10)
MouseMove(30, 30)
Log(Format("Clicking at {1}, {2}", x, y))
size := 20
g := Gui("-Caption +AlwaysOnTop +ToolWindow")
g.BackColor := "Red"
g.Show(Format(
"x{} y{} w{} h{} NoActivate"
, x - size // 2
, y - size // 2
, size
, size
))
hRegion := DllCall(
"CreateEllipticRgn"
, "Int", 0
, "Int", 0
, "Int", size
, "Int", size
, "Ptr"
)
DllCall("SetWindowRgn", "Ptr", g.Hwnd, "Ptr", hRegion, "Int", true)
WinSetTransparent(255, g.Hwnd)
SetTimer(() => g.Destroy(), -500)
}
FindImageInWindow(winTitle, imageFile, &outX, &outY, timeoutMs := 10000, intervalMs := 250)
{
WinGetPos(&wx, &wy, &ww, &wh, winTitle)
hBitmap := LoadPicture(imageFile)
if !hBitmap {
throw Error("LoadPicture failed: " imageFile)
}
bm := Buffer(32, 0) ; BITMAP structure on x64
DllCall("GetObject", "Ptr", hBitmap, "Int", bm.Size, "Ptr", bm)
width := NumGet(bm, 4, "Int")
height := NumGet(bm, 8, "Int")
startTime := A_TickCount
timeLeft := 1
Log(Format("Searching for button file {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
searchImage := Format("*10 {}", imageFile)
Log("SearchImage: " searchImage)
while (timeLeft > 0)
{
if ImageSearch(&x, &y, wx, wy, wx + ww, wy + wh, searchImage)
{
outX := x + Floor(width / 2)
outY := y + Floor(height / 2)
Log("Found button!")
return
}
Sleep intervalMs
timeLeft := timeoutMs - (A_TickCount - startTime)
ToolTip(Format("Searching for button {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
}
throw Error(Format("Failed to find button {} in window {}", imageFile, winTitle))
}
ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250)
{
FindImageInWindow(winTitle, imageFile, &x, &y, timeoutMs, intervalMs)
ClickWithMarker(x, y)
}
Log("Waiting for the installer window to appear...")
winTitle := "Hermes"
try {
WinWait(winTitle, , 30)
} catch {
throw Error("Hermes installer window did not appear within 30s")
}
WinGetPos(&x, &y, &w, &h, winTitle)
Log(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h))
ClickCenterOfImageInWindow(winTitle, A_ScriptDir "\install-button.png")
; wait for install to finish
FindImageInWindow(winTitle, A_ScriptDir "\launch-button.png", &launchX, &launchY, 1000 * 60 * 8)
; after we find the install button, close the window so we don't launch hermes.
WinClose(winTitle)
; yay :D
Sleep(2000)
; done
ExitApp(0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

+2 -2
View File
@@ -14,8 +14,8 @@ Provides subcommands for:
import os
import sys
__version__ = "0.18.2"
__release_date__ = "2026.7.7.2"
__version__ = "0.19.0"
__release_date__ = "2026.7.20"
def _ensure_utf8():
+31 -9
View File
@@ -1720,7 +1720,6 @@ def list_authenticated_providers(
results: List[dict] = []
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)
_current_provider_norm = str(current_provider or "").strip().lower()
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
@@ -1849,6 +1848,7 @@ def list_authenticated_providers(
# --- 1. Check Hermes-mapped providers ---
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
from hermes_cli.models import _PROVIDER_ALIASES as _CANON_ALIASES
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
# Skip vendor names that are merely aliases routing through an
@@ -1866,10 +1866,30 @@ def list_authenticated_providers(
and _alias_target in _AGG_PROVIDERS
):
continue
# Skip aliases that map to the same models.dev provider (e.g.
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
# The first one with valid credentials wins (#10526).
if mdev_id in seen_mdev_ids:
# Resolve the canonical provider profile name. Skip hermes_ids
# that are mere aliases resolving to a different canonical profile
# (e.g. "kimi" and "moonshot" both → "kimi-coding"). Only process
# entries whose hermes_id matches the canonical profile name so
# distinct profiles (e.g. kimi-coding, kimi-coding-cn) each get
# their own picker row.
_canonical = hermes_id
try:
from providers import get_provider_profile as _gpp
_prof = _gpp(hermes_id)
if _prof is not None:
_canonical = _prof.name
except Exception:
pass
if _canonical != hermes_id:
continue
# Skip duplicates: another entry with the same slug was already
# emitted (e.g. two PROVIDER_TO_MODELS_DEV entries routing to the
# same hermes_id). Distinct canonical profiles that share a
# models.dev ID (e.g. kimi-coding and kimi-coding-cn → kimi-for-coding)
# are both allowed through since they have different slugs.
slug = hermes_id
if slug.lower() in seen_slugs:
continue
if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded:
continue
@@ -1940,21 +1960,23 @@ def list_authenticated_providers(
else:
top = model_ids[:max_models] if max_models is not None else model_ids
slug = hermes_id
pinfo = _mdev_pinfo(mdev_id)
display_name = pinfo.name if pinfo else mdev_id
display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id)
results.append({
"slug": slug,
"name": display_name,
"is_current": slug == current_provider or mdev_id == current_provider,
"is_current": (
slug == current_provider
or hermes_id == current_provider
or mdev_id == current_provider
),
"is_user_defined": False,
"models": top,
"total_models": total,
"source": "built-in",
})
seen_slugs.add(slug.lower())
seen_mdev_ids.add(mdev_id)
_record_builtin_endpoint(slug)
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
+6
View File
@@ -319,6 +319,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"minimaxai/minimax-m3",
],
"kimi-coding": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
@@ -330,6 +331,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-0905-preview",
],
"kimi-coding-cn": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
@@ -341,6 +345,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"step-3.5-flash-2603",
],
"moonshot": [
"kimi-k3",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
@@ -463,6 +468,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"nemotron-3-ultra-free",
],
"opencode-go": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
+30
View File
@@ -765,6 +765,36 @@ def resolve_provider_full(
if user_pdef is not None:
return user_pdef
# 0.5 Exact Hermes provider IDs must win over LOSSY alias collapsing.
# Example: kimi-coding-cn should stay distinct from kimi-coding instead of
# normalizing through the shared models.dev alias "kimi-for-coding".
# A collapse is lossy only when MULTIPLE distinct registry providers
# normalize to the same canonical name — resolving through the alias
# would then lose which one the caller meant. Single-entry rewrites
# (e.g. "copilot" → "github-copilot") are correct routing and must keep
# resolving through the built-in chain below so overlay transports apply.
if canonical != raw:
try:
from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY
_pcfg = _AUTH_PROVIDER_REGISTRY.get(raw)
if _pcfg is not None:
_collapsed_siblings = [
_rid
for _rid in _AUTH_PROVIDER_REGISTRY
if normalize_provider(_rid) == canonical
]
if len(_collapsed_siblings) > 1:
return ProviderDef(
id=_pcfg.id,
name=_pcfg.name,
transport="openai_chat",
api_key_env_vars=tuple(_pcfg.api_key_env_vars or ()),
base_url=_pcfg.inference_base_url or "",
source="hermes-auth-registry",
)
except Exception:
pass
# 1. Built-in (models.dev + overlays)
pdef = get_provider(canonical)
if pdef is not None:
+3 -3
View File
@@ -100,15 +100,15 @@ _DEFAULT_PROVIDER_MODELS = {
"google/gemini-2.5-pro", "google/gemini-2.5-flash",
],
"zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"kilocode": ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-5", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
"opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
"opencode-go": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
"huggingface": [
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
+6 -1
View File
@@ -1346,7 +1346,12 @@ def _apply_main_model_assignment(
if api_key.strip():
model_cfg["api_key"] = api_key.strip()
model_cfg.pop("api", None)
elif model_cfg.get("api_key") and new_provider != prev_provider:
elif (model_cfg.get("api_key") or model_cfg.get("api")) and new_provider != prev_provider:
# A stale endpoint secret can live under the legacy ``api`` alias with
# no ``api_key`` (the resolver still reads ``model.api`` as a key), so
# the switch-clears-the-key path must trigger on either field — else the
# old endpoint's secret survives in config.yaml and contaminates a later
# custom resolution. clear_model_endpoint_credentials scrubs both.
clear_model_endpoint_credentials(model_cfg, clear_api_mode=False)
if new_provider != prev_provider:
clear_model_endpoint_credentials(model_cfg, clear_api_key=False)
+41
View File
@@ -5066,6 +5066,47 @@ class SessionDB:
)
return model_history, display_history
def get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]:
"""Return the ancestor-only display messages for a session lineage.
These are messages from parent/grandparent sessions (compression
ancestors) that appear in the display transcript but NOT in the
tip session's model-fed history. Used by ``session.resume`` to
build the ``display_history_prefix`` that ``_live_session_payload``
prepends to the live model history.
Previously the prefix was calculated as
``display_history[:len(display) - len(raw)]``, but that overcounts
when ``repair_message_sequence`` removes messages from the MIDDLE
of the tip history (e.g. verification candidates collapsed by the
consecutive-assistant merge) — the length difference includes both
ancestor messages AND repair-removed tip messages, but the slice
only captures the first N display messages (which are tip messages
when there are no ancestors), causing duplication. This method
returns ONLY the genuine ancestor messages, identified by
``session_id != tip_session_id``. (#65919)
"""
session_ids = self._session_lineage_root_to_tip(session_id)
if len(session_ids) <= 1:
return []
with self._lock:
placeholders = ",".join("?" for _ in session_ids)
rows = self._conn.execute(
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
"ORDER BY id",
tuple(session_ids),
).fetchall()
ancestor_rows = [r for r in rows if r["session_id"] != session_id]
if not ancestor_rows:
return []
return self._rows_to_conversation(
ancestor_rows,
session_id=session_id,
include_ancestors=True,
repair_alternation=False,
)
def get_conversation_root(self, session_id: str) -> str:
"""Return the ROOT id of *session_id*'s lineage chain.
-3
View File
@@ -43,9 +43,6 @@
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
# Force Node to use Nix's playwright-test binary instead of node_modules/.bin
export PATH="${pkgs.playwright-test}/bin:$PATH"
# for the devshell to pick up the src
export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel)
echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT"
-64
View File
@@ -155,7 +155,6 @@
"devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4",
"@playwright/test": "=1.58.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
@@ -377,22 +376,6 @@
}
}
},
"apps/desktop/node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"apps/desktop/node_modules/@types/node": {
"version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
@@ -403,53 +386,6 @@
"undici-types": "~6.21.0"
}
},
"apps/desktop/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"apps/desktop/node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"apps/desktop/node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"apps/desktop/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+42 -12
View File
@@ -152,11 +152,24 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
_MARKDOWN_HINT_RE = re.compile(
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
# Pipe table: any header line + separator line both starting with '|'.
r"(^\|.*\|\s*\n\|[-:|\s]+\|)"
# Headings, lists, code, bold/italic/strike/underline, links, blockquotes.
r"|(^#{1,6}\s)"
r"|(^\s*[-*]\s)"
r"|(^\s*\d+\.\s)"
r"|(^\s*---+\s*$)"
r"|(```)"
r"|(`[^`\n]+`)"
r"|(\*\*[^*\n].+?\*\*)"
r"|(~~[^~\n].+?~~)"
r"|(<u>.+?</u>)"
r"|(\*[^*\n]+\*)"
r"|(\[[^\]]+\]\([^)]+\))"
r"|(^>\s)",
re.MULTILINE,
)
# Detect markdown tables: a line starting with | followed by a separator line.
# Feishu post-type 'md' elements do not render tables, so we force text mode.
# Backwards-compatible alias retained because external callers reference it.
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
@@ -1897,11 +1910,21 @@ class FeishuAdapter(BasePlatformAdapter):
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
# When chunking splits a long markdown response, an individual chunk
# can end up as plain prose that doesn't match the per-chunk hint
# regex — so it would be sent as ``msg_type=text`` and the user would
# see literal ``**bold``/``## heading``/code fences in the Feishu
# client while other chunks render correctly. Lock the markdown
# decision at the whole-message level so every chunk consistently
# uses ``post``. See #26841.
prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted))
last_response = None
try:
for chunk in chunks:
msg_type, payload = self._build_outbound_payload(chunk)
msg_type, payload = self._build_outbound_payload(
chunk, prefer_post=prefer_post,
)
try:
response = await self._feishu_send_with_retry(
chat_id=chat_id,
@@ -4532,14 +4555,21 @@ class FeishuAdapter(BasePlatformAdapter):
# Outbound payload construction and send pipeline
# =========================================================================
def _build_outbound_payload(self, content: str) -> tuple[str, str]:
# Feishu post-type 'md' elements do not render markdown tables; sending
# table content as post causes the message to appear blank on the client.
# Force plain text for anything that looks like a markdown table.
if _MARKDOWN_TABLE_RE.search(content):
text_payload = {"text": content}
return "text", json.dumps(text_payload, ensure_ascii=False)
if _MARKDOWN_HINT_RE.search(content):
def _build_outbound_payload(
self, content: str, *, prefer_post: bool = False,
) -> tuple[str, str]:
# Empirically (issue #52786), current Feishu clients render markdown
# tables inside ``post``-type ``md`` elements natively. The previous
# table-downgrade branch forced any table-containing message to
# ``text``, which left Feishu readers seeing the raw pipe-and-dash
# source instead of a rendered table. Trust the common markdown path
# for table content too.
#
# ``prefer_post`` lets ``send`` treat the chunk as part of a larger
# markdown document: when a long markdown reply is split at
# MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise
# mis-classify a plain-prose chunk as ``text``. See #26841.
if prefer_post or _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
return "text", json.dumps(text_payload, ensure_ascii=False)
+51 -11
View File
@@ -41,6 +41,7 @@ Environment variables:
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false)
MATRIX_MAX_MESSAGE_LENGTH Outbound message chunk size in characters (default: 16000)
MATRIX_APPROVAL_REQUIRE_SENDER
Require reaction controls to come from the original requester
when requester metadata is available (default: true)
@@ -352,9 +353,39 @@ class _MatrixChoicePickerPrompt:
bot_reaction_events: dict[str, str] = field(default_factory=dict)
# Matrix message size limit (4000 chars practical, spec has no hard limit
# but clients render poorly above this).
MAX_MESSAGE_LENGTH = 4000
# Matrix message size limit. The spec allows large events (~65 KB), but very
# large bodies can render poorly in some clients. The previous 4,000-char
# default was overly conservative and split Markdown tables mid-row (#53026).
DEFAULT_MAX_MESSAGE_LENGTH = 16000
MATRIX_MAX_MESSAGE_LENGTH_CEILING = 65535
def _resolve_max_message_length(config) -> int:
"""Resolve outbound chunk size from config, env, or plugin registry."""
extra = getattr(config, "extra", {}) or {}
raw = extra.get("max_message_length")
if raw is None:
raw = os.getenv("MATRIX_MAX_MESSAGE_LENGTH")
if raw is None:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get("matrix")
if entry and entry.max_message_length:
raw = entry.max_message_length
except Exception:
pass
if raw is None:
return DEFAULT_MAX_MESSAGE_LENGTH
try:
value = int(raw)
except (TypeError, ValueError):
return DEFAULT_MAX_MESSAGE_LENGTH
return max(500, min(value, MATRIX_MAX_MESSAGE_LENGTH_CEILING))
# Back-compat alias for callers/tests that import the module constant.
MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH
# Store directory for E2EE keys and sync state.
# Uses get_hermes_home() so each profile gets its own Matrix store.
@@ -799,21 +830,28 @@ class MatrixAdapter(BasePlatformAdapter):
"""Gateway adapter for Matrix (any homeserver)."""
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
splits_long_messages = True # send() chunks via truncate_message(max_message_length)
# Matrix clients commonly reserve typed "/" for client-local commands;
# the adapter accepts "!command" as the alias that always reaches Hermes
# (see _normalize_matrix_bang_command), so instruction text shows "!".
typed_command_prefix = "!"
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
_SPLIT_THRESHOLD = 3900
# Class-level defaults so partially-constructed instances (tests build
# adapters via object.__new__ without __init__) keep working; __init__
# overrides both from _resolve_max_message_length().
max_message_length = DEFAULT_MAX_MESSAGE_LENGTH
_split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MATRIX)
self.max_message_length = _resolve_max_message_length(config)
# Mirror other platform adapters for tests/tooling that read MAX_MESSAGE_LENGTH.
self.MAX_MESSAGE_LENGTH = self.max_message_length
# When a chunk is near the outbound limit, a continuation is almost certain.
self._split_threshold = max(100, self.max_message_length - 100)
self._homeserver: str = (
config.extra.get("homeserver", "") or os.getenv("MATRIX_HOMESERVER", "")
).rstrip("/")
@@ -1612,7 +1650,7 @@ class MatrixAdapter(BasePlatformAdapter):
return SendResult(success=True)
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
chunks = self.truncate_message(formatted, self.max_message_length)
last_event_id = None
for i, chunk in enumerate(chunks):
@@ -3607,7 +3645,7 @@ class MatrixAdapter(BasePlatformAdapter):
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
if last_len >= self._split_threshold:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
@@ -4690,6 +4728,8 @@ def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None:
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
if "max_message_length" in matrix_cfg and not os.getenv("MATRIX_MAX_MESSAGE_LENGTH"):
os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"])
return None
@@ -4735,7 +4775,7 @@ def register(ctx) -> None:
allow_all_env="MATRIX_ALLOW_ALL_USERS",
cron_deliver_env_var="MATRIX_HOME_ROOM",
standalone_sender_fn=_standalone_send,
max_message_length=4000,
max_message_length=DEFAULT_MAX_MESSAGE_LENGTH,
emoji="🔐",
allow_update_command=True,
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.18.2"
version = "0.19.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
+2
View File
@@ -49,6 +49,7 @@ IGNORED_PATTERNS = [
re.compile(r"^dependabot", re.IGNORECASE),
re.compile(r"^renovate", re.IGNORECASE),
re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE),
re.compile(r"^nousbot(-eng)?$", re.IGNORECASE),
re.compile(r"^Ubuntu$", re.IGNORECASE),
]
@@ -59,6 +60,7 @@ IGNORED_EMAILS = {
"cursoragent@cursor.com",
"hermes@nousresearch.com",
"hermes-audit@example.com",
"nousbot@nousresearch.com",
"hermes@habibilabs.dev",
"omx@oh-my-codex.dev",
"codex@openai.com",
+58 -12
View File
@@ -17,6 +17,14 @@ Behaviour (all behaviours selectable via env var ``MOCK_LSP_SCRIPT``):
(simulates a crashing server).
- ``"slow"`` — same as ``clean`` but sleeps 1s before responding to
``initialize`` (lets us test timeout behaviour).
- ``"stale"`` — pushes one error on ``didOpen``, then goes SILENT on
``didChange`` (no push) and rejects the pull endpoint with
method-not-found. Models a slow tsserver that hasn't re-checked
the edited content yet — the ghost-diagnostics scenario.
- ``"slow_push"`` — like ``stale`` on didOpen (one error) but on
``didChange`` sleeps ``MOCK_LSP_PUSH_DELAY`` seconds (default 1.0)
and then pushes EMPTY diagnostics. Models a server that fixes
the ghost if you actually wait for it. Pull endpoint rejects.
The script writes JSON-RPC framed messages to stdout and reads from
stdin. No third-party dependencies — uses only stdlib so it runs
@@ -96,20 +104,47 @@ def main():
td = params.get("textDocument") or {}
uri = td.get("uri", "")
version = td.get("version", 0)
is_change = msg.get("method") == "textDocument/didChange"
error_diag = [
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 5},
},
"severity": 1,
"code": "MOCK001",
"source": "mock-lsp",
"message": "synthetic error from mock-lsp",
}
]
if script == "stale":
# Ghost scenario: publish an error for the ORIGINAL
# content, then never publish again after edits.
if not is_change:
write_message(
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "version": version, "diagnostics": error_diag},
}
)
continue
if script == "slow_push":
diagnostics = error_diag
if is_change:
time.sleep(float(os.environ.get("MOCK_LSP_PUSH_DELAY", "1.0")))
diagnostics = []
write_message(
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "version": version, "diagnostics": diagnostics},
}
)
continue
diagnostics = []
if script == "errors":
diagnostics = [
{
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 5},
},
"severity": 1,
"code": "MOCK001",
"source": "mock-lsp",
"message": "synthetic error from mock-lsp",
}
]
diagnostics = error_diag
write_message(
{
"jsonrpc": "2.0",
@@ -124,6 +159,17 @@ def main():
continue
if msg.get("method") == "textDocument/diagnostic":
if script in {"stale", "slow_push"}:
# These scripts model push-only servers so the ghost
# can't be papered over by the pull channel.
write_message(
{
"jsonrpc": "2.0",
"id": msg["id"],
"error": {"code": -32601, "message": "method not found"},
}
)
continue
# Pull endpoint — return empty.
write_message(
{
+251
View File
@@ -0,0 +1,251 @@
"""Regression tests for the "ghost diagnostics" staleness bug.
Scenario: the agent edits a TypeScript file, tsserver takes a long
time to re-check it, and the old diagnostics (for the PRE-edit
content) were reported as if they were current — the agent then
chases errors it already fixed.
The contract under test:
- ``wait_for_diagnostics`` must NOT be satisfied by diagnostics left
over from a previous edit cycle; it returns True only when fresh
(post-didChange) data arrived, False on timeout.
- ``diagnostics_for(fresh_only=True)`` must exclude stale stores.
- ``LSPService.get_diagnostics_sync`` must return [] ("no data")
rather than the stale diagnostics when the server never re-checks
within the wait budget, and must NOT mark the server broken.
- A slow-but-eventually-correct server ("slow_push") is waited on,
honouring the configured ``lsp.wait_timeout``.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
from agent.lsp.client import LSPClient
MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py")
def _client(workspace: Path, script: str, **env_extra: str) -> LSPClient:
env = {
"MOCK_LSP_SCRIPT": script,
"PYTHONPATH": os.environ.get("PYTHONPATH", ""),
**env_extra,
}
return LSPClient(
server_id=f"mock-{script}",
workspace_root=str(workspace),
command=[sys.executable, MOCK_SERVER],
env=env,
cwd=str(workspace),
)
@pytest.mark.asyncio
async def test_stale_push_does_not_satisfy_wait(tmp_path: Path):
"""A push from the previous edit cycle must not end the wait early.
The 'stale' mock publishes an error for the original content and
then goes silent — the wait after the edit must time out (False),
not return instantly on the leftover push.
"""
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "stale")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
assert len(client.diagnostics_for(str(f))) == 1 # pre-edit error is real
# Fix the file. The stale server never re-checks.
f.write_text("good code\n")
v1 = await client.open_file(str(f), language_id="python")
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=1.0)
assert fresh is False, "wait must not be satisfied by pre-edit leftovers"
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_fresh_only_excludes_stale_stores(tmp_path: Path):
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "stale")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
f.write_text("good code\n")
await client.open_file(str(f), language_id="python")
# Merged legacy view still exposes the leftover push...
assert len(client.diagnostics_for(str(f))) == 1
# ...but the fresh-only view correctly reports no verdict yet.
assert client.diagnostics_for(str(f), fresh_only=True) == []
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_slow_push_is_waited_for(tmp_path: Path):
"""A server that re-checks slowly (but within budget) gets waited on,
and the fresh (clean) result replaces the old error."""
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "slow_push", MOCK_LSP_PUSH_DELAY="0.8")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
assert len(client.diagnostics_for(str(f), fresh_only=True)) == 1
f.write_text("good code\n")
v1 = await client.open_file(str(f), language_id="python")
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=5.0)
assert fresh is True, "slow push within budget must satisfy the wait"
assert client.diagnostics_for(str(f), fresh_only=True) == []
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_wait_timeout_param_overrides_mode_budget(tmp_path: Path):
"""The explicit timeout must control the wait budget (config plumb)."""
import asyncio
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "stale")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
f.write_text("good code\n")
v1 = await client.open_file(str(f), language_id="python")
loop = asyncio.get_event_loop()
start = loop.time()
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=0.5)
elapsed = loop.time() - start
assert fresh is False
# Must respect ~0.5s, not the 5s document default.
assert elapsed < 3.0
finally:
await client.shutdown()
@pytest.mark.asyncio
async def test_stale_pull_result_dropped_when_change_races(tmp_path: Path):
"""A pull answered for pre-edit content must not read as fresh after
a didChange raced past it (version-tag anchoring)."""
f = tmp_path / "x.py"
f.write_text("bad code\n")
client = _client(tmp_path, "clean")
await client.start()
try:
v0 = await client.open_file(str(f), language_id="python")
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
doc = client._docs[os.path.abspath(str(f))]
assert doc.fresh_pull()
# Simulate an edit racing in: the version bump invalidates the
# stored pull without any explicit clearing.
f.write_text("good code\n")
await client.open_file(str(f), language_id="python")
assert not doc.fresh_pull()
assert client.diagnostics_for(str(f), fresh_only=True) == []
finally:
await client.shutdown()
# ---------------------------------------------------------------------------
# Service-level: stale data must surface as "no data", never as errors
# ---------------------------------------------------------------------------
def _install_mock_server(script: str, server_id: str = "pyright"):
"""Replace one registered server with a wrapper spawning the mock.
Mirrors the helper in test_service.py — reuse pyright so .py files
route to the mock without a real toolchain.
"""
from agent.lsp.servers import SERVERS, ServerContext, ServerDef, SpawnSpec
target_index = next(i for i, s in enumerate(SERVERS) if s.server_id == server_id)
original = SERVERS[target_index]
def _spawn(root: str, ctx: ServerContext) -> SpawnSpec:
return SpawnSpec(
command=[sys.executable, MOCK_SERVER],
workspace_root=root,
cwd=root,
env={"MOCK_LSP_SCRIPT": script},
initialization_options={},
)
SERVERS[target_index] = ServerDef(
server_id=server_id,
extensions=original.extensions,
resolve_root=lambda fp, ws: ws,
build_spawn=_spawn,
seed_first_push=False,
description="mock " + server_id,
)
return target_index, original
@pytest.fixture
def stale_repo(monkeypatch, tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
(repo / ".git").mkdir()
(repo / "pyproject.toml").write_text("")
monkeypatch.chdir(str(repo))
idx, original = _install_mock_server("stale")
yield repo
from agent.lsp.servers import SERVERS
SERVERS[idx] = original
def test_service_reports_no_data_not_stale_errors(stale_repo):
"""When the server never re-checks the edited content in budget,
get_diagnostics_sync must return [] and keep the server usable."""
from agent.lsp.manager import LSPService
f = stale_repo / "x.py"
f.write_text("bad code\n")
svc = LSPService(
enabled=True,
wait_mode="document",
wait_timeout=1.0,
install_strategy="manual",
)
try:
# First contact: didOpen gets the (real) pre-edit error push.
first = svc.get_diagnostics_sync(str(f), delta=False)
assert len(first) == 1
# Edit the file — mock never re-publishes (slow tsserver model).
f.write_text("good code\n")
ghost = svc.get_diagnostics_sync(str(f), delta=False)
assert ghost == [], "stale pre-edit error must not be reported as current"
# Not marked broken: slow is not dead.
assert svc.enabled_for(str(f))
status = svc.get_status()
assert status["broken"] == []
finally:
svc.shutdown()
+16
View File
@@ -1631,6 +1631,22 @@ class TestBuildAnthropicKwargs:
assert _supports_xhigh_effort(m) is True, m
assert _forbids_sampling_params(m) is False, m
def test_bare_k3_coding_plan_slug_is_kimi_family(self):
"""Kimi Coding Plan serves K3 as the bare slug ``k3`` — it must be
classified as Kimi family (adaptive thinking) even on proxied
endpoints where only the model name is available. Lookalike
non-Kimi names must NOT match the exact-slug rule."""
from agent.anthropic_adapter import (
_model_name_is_kimi_family,
_supports_adaptive_thinking,
)
for m in ("k3", "K3", "moonshotai/k3", "k3.1-preview", "k3-turbo"):
assert _model_name_is_kimi_family(m) is True, m
assert _supports_adaptive_thinking("k3") is True
# Prefix-lookalikes without a separator must not be swept in.
for m in ("k30", "k3000-chat", "keras-3"):
assert _model_name_is_kimi_family(m) is False, m
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
@@ -12,7 +12,7 @@ MOONSHOT = "https://api.moonshot.cn/anthropic"
DEEPSEEK = "https://api.deepseek.com/anthropic"
def _thinking_on_replay(base_url, signature=SIG):
def _thinking_on_replay(base_url, signature=SIG, model="k3"):
"""Normalize a thinking+text turn, store it, convert to the next-turn request,
and return its thinking blocks."""
response = SimpleNamespace(
@@ -34,7 +34,7 @@ def _thinking_on_replay(base_url, signature=SIG):
stored,
{"role": "user", "content": "q2"},
]
_sys, out = convert_messages_to_anthropic(messages, base_url=base_url, model="k3")
_sys, out = convert_messages_to_anthropic(messages, base_url=base_url, model=model)
assistant = [m for m in out if m.get("role") == "assistant"][0]
return [b for b in assistant["content"] if isinstance(b, dict) and b.get("type") == "thinking"]
@@ -54,7 +54,21 @@ def test_moonshot_keeps_signed_thinking():
def test_deepseek_still_strips_signed_thinking():
assert not _thinking_on_replay(DEEPSEEK)
# A DeepSeek model on the DeepSeek Anthropic endpoint must strip signed
# thinking on replay. (The model must be a real DeepSeek slug: the bare
# ``k3`` slug is now classified as Kimi family, and a Kimi-family MODEL
# name deliberately preserves thinking regardless of gateway hostname —
# the proxied-endpoint path, see _is_kimi_family_endpoint.)
assert not _thinking_on_replay(DEEPSEEK, model="deepseek-reasoner")
def test_kimi_model_name_on_foreign_gateway_keeps_thinking():
"""A Kimi-family model slug replayed through a non-Kimi gateway hostname
keeps its thinking blocks — upstream Kimi still enforces its replay
semantics no matter what host fronts it (hermes-agent#13848, #17057).
Covers both the named and bare Coding Plan slugs."""
for model in ("kimi-k2.5", "k3"):
assert _thinking_on_replay(DEEPSEEK, model=model), model
def test_direct_anthropic_keeps_signed_on_latest():
+46 -4
View File
@@ -175,7 +175,15 @@ class TestEstimateRequestTokensRough:
class TestDefaultContextLengths:
def test_k3_context_is_scoped_to_confirmed_coding_endpoint(self):
"""K3's 1 Mi context must not leak to unverified Moonshot endpoints."""
"""The bare ``k3`` slug's 1 Mi context must not leak to unverified endpoints.
The named ``kimi-k3`` / ``kimi-k3-cot`` slugs resolve to 1 Mi
EVERYWHERE via DEFAULT_CONTEXT_LENGTHS — the window is a property of
the model, served at 1M on api.moonshot.ai and api.moonshot.cn alike
(verified against models.dev + OpenRouter live metadata). Only the
bare ``k3`` slug, which exists solely on the Kimi Coding Plan
endpoint, stays endpoint-scoped.
"""
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
@@ -199,14 +207,22 @@ class TestDefaultContextLengths:
)
for base_url in accepted_urls:
assert get_model_context_length(
"k3", provider="kimi-coding", base_url=base_url
) == 1_048_576
for model in ("k3", "kimi-k3", "kimi-k3-cot"):
assert get_model_context_length(
model, provider="kimi-coding", base_url=base_url
) == 1_048_576
for base_url in rejected_urls:
# Bare slug: endpoint-scoped, must NOT leak off-endpoint.
assert get_model_context_length(
"k3", provider="kimi-coding", base_url=base_url
) != 1_048_576
# Named slugs: global DEFAULT_CONTEXT_LENGTHS entry applies
# everywhere the model is actually named kimi-k3.
for model in ("kimi-k3", "kimi-k3-cot"):
assert get_model_context_length(
model, provider="kimi-coding", base_url=base_url
) == 1_048_576
def test_grok_substring_matching(self):
# Longest-first substring matching must resolve the real xAI model
@@ -330,6 +346,32 @@ class TestDefaultContextLengths:
assert get_model_context_length("glm-5") == 202752
assert get_model_context_length("glm-5.1") == 202752
def test_kimi_k3_context_1m(self):
"""Kimi K3 must resolve to 1M, not the generic Kimi fallback of 256K.
Context window verified against models.dev and OpenRouter live
metadata (1,048,576; 2026-07). Kimi K3 is the current flagship model
with a 1M-token context window — the value matches the
endpoint-scoped override in _endpoint_scoped_context_length.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
assert DEFAULT_CONTEXT_LENGTHS["kimi-k3"] == 1_048_576
assert DEFAULT_CONTEXT_LENGTHS["kimi"] == 262144
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None):
# Kimi K3 (1M) must NOT fall through to the generic 256K entry
assert get_model_context_length("kimi-k3") == 1_048_576
# Vendor-prefixed forms (kimi provider, openrouter)
assert get_model_context_length("kimi/kimi-k3") == 1_048_576
assert get_model_context_length("moonshotai/kimi-k3") == 1_048_576
# Older/unknown Kimi models still resolve to 256K fallback
assert get_model_context_length("kimi-k2.6") == 262144
assert get_model_context_length("kimi-k2") == 262144
def test_openrouter_live_metadata_beats_hardcoded_catchall(self):
"""OpenRouter-routed slugs resolve via the live OR catalog before the
hardcoded family catch-all.
+72 -5
View File
@@ -29,6 +29,10 @@ class TestMoonshotModelDetection:
[
"kimi-k2.6",
"kimi-k2-thinking",
"k3",
"K3",
"moonshotai/k3",
"k3.1-preview",
"moonshotai/Kimi-K2.6",
"moonshotai/kimi-k2.6",
"nous/moonshotai/kimi-k2.6",
@@ -184,9 +188,10 @@ class TestTopLevelGuarantees:
"""The returned top-level schema is always a well-formed object."""
def test_non_dict_input_returns_empty_object(self):
assert sanitize_moonshot_tool_parameters(None) == {"type": "object", "properties": {}}
assert sanitize_moonshot_tool_parameters("garbage") == {"type": "object", "properties": {}}
assert sanitize_moonshot_tool_parameters([]) == {"type": "object", "properties": {}}
empty = {"type": "object", "properties": {}, "required": []}
assert sanitize_moonshot_tool_parameters(None) == empty
assert sanitize_moonshot_tool_parameters("garbage") == empty
assert sanitize_moonshot_tool_parameters([]) == empty
def test_non_object_top_level_coerced(self):
params = {"type": "string"}
@@ -208,6 +213,66 @@ class TestTopLevelGuarantees:
assert "type" not in params["properties"]["q"]
class TestRequiredArray:
"""Rule 4: every object schema must carry a ``required`` array (#66835)."""
def test_empty_object_gets_empty_required(self):
out = sanitize_moonshot_tool_parameters({"type": "object", "properties": {}})
assert out["required"] == []
def test_object_with_only_optional_props_gets_empty_required(self):
params = {
"type": "object",
"properties": {"q": {"type": "string"}},
}
out = sanitize_moonshot_tool_parameters(params)
assert out["required"] == []
def test_existing_required_preserved(self):
params = {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
}
out = sanitize_moonshot_tool_parameters(params)
assert out["required"] == ["q"]
def test_dangling_required_pruned(self):
params = {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q", "ghost"],
}
out = sanitize_moonshot_tool_parameters(params)
assert out["required"] == ["q"]
def test_non_list_required_replaced(self):
params = {
"type": "object",
"properties": {},
"required": "q", # invalid: string, not array
}
out = sanitize_moonshot_tool_parameters(params)
assert out["required"] == []
def test_nested_object_property_gets_required(self):
params = {
"type": "object",
"properties": {
"filter": {"type": "object", "properties": {}},
},
}
out = sanitize_moonshot_tool_parameters(params)
assert out["properties"]["filter"]["required"] == []
assert out["required"] == []
def test_coerced_top_level_gets_required(self):
# A non-object top level is forced to object and must gain required.
out = sanitize_moonshot_tool_parameters({"type": "string"})
assert out["type"] == "object"
assert out["required"] == []
class TestToolListSanitizer:
"""sanitize_moonshot_tools() walks an OpenAI-format tool list."""
@@ -235,8 +300,10 @@ class TestToolListSanitizer:
]
out = sanitize_moonshot_tools(tools)
assert out[0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
# Second tool already clean — should be structurally equivalent
assert out[1]["function"]["parameters"] == {"type": "object", "properties": {}}
# Second tool: empty object gains the required-array Moonshot demands
assert out[1]["function"]["parameters"] == {
"type": "object", "properties": {}, "required": []
}
def test_empty_list_is_passthrough(self):
assert sanitize_moonshot_tools([]) == []
+5
View File
@@ -1169,6 +1169,11 @@ class TestPromptBuilderConstants:
assert "Matrix" in hint
assert "MEDIA:" in hint
assert "Markdown" in hint
# Regression (#52552): the hint must steer models away from Markdown
# tables — popular Matrix clients don't render HTML tables and the
# cells collapse into one continuous line.
assert "table" in hint.lower()
assert "Do NOT use Markdown tables" in hint
def test_platform_hints_feishu(self):
hint = PLATFORM_HINTS["feishu"]
@@ -747,6 +747,28 @@ class TestChatCompletionsKimi:
)
assert kw["tools"][0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
def test_moonshot_outgoing_schema_carries_required_array(self, transport):
"""Moonshot 400s on object schemas without an explicit `required` array
(#66835). Assert the wire-level tool schema — what actually leaves the
transport — carries `required: []` on a zero-required-param tool."""
tools = [
{
"type": "function",
"function": {
"name": "browser_snapshot",
"description": "Snapshot",
"parameters": {"type": "object", "properties": {}},
},
},
]
kw = transport.build_kwargs(
model="moonshotai/kimi-k3",
messages=[{"role": "user", "content": "Hi"}],
tools=tools,
max_tokens_param_fn=lambda n: {"max_tokens": n},
)
assert kw["tools"][0]["function"]["parameters"]["required"] == []
def test_non_moonshot_tools_are_not_mutated(self, transport):
"""Other models don't go through the Moonshot sanitizer."""
original_params = {
+121
View File
@@ -2690,6 +2690,127 @@ class TestAdapterBehavior(unittest.TestCase):
[[{"tag": "md", "text": "---\n1. 第一项\n 2. 子项\n- 外层\n - 内层\n<u>下划线</u> 和 ~~删除线~~"}]],
)
@patch.dict(os.environ, {}, clear=True)
def test_build_outbound_payload_uses_post_for_markdown_table(self):
from gateway.config import PlatformConfig
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
content = "| Name | Score |\n| --- | ---: |\n| Ada | 10 |"
msg_type, raw_payload = adapter._build_outbound_payload(content)
self.assertEqual(msg_type, "post")
payload = json.loads(raw_payload)
self.assertEqual(
payload["zh_cn"]["content"],
[[{"tag": "md", "text": content}]],
)
@patch.dict(os.environ, {}, clear=True)
def test_send_uses_post_for_every_chunk_of_multi_chunk_markdown(self):
"""Regression for #26841: when a long Markdown message is split
across multiple chunks, every chunk must go out as
``msg_type=post`` — including chunk 1. The bug was that the
first chunk often had only plain prose (the per-chunk regex
didn't match) and was sent as ``text``, so users saw literal
``**bold``/``## heading``/code fences while later chunks
rendered correctly.
"""
from gateway.config import PlatformConfig
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
captured = []
class _MessageAPI:
def create(self, request):
captured.append(request)
return SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(
message_id=f"om_chunk_{len(captured)}",
),
)
adapter._client = SimpleNamespace(
im=SimpleNamespace(
v1=SimpleNamespace(
message=_MessageAPI(),
)
)
)
async def _direct(func, *args, **kwargs):
return func(*args, **kwargs)
# Force a deterministic split so the test doesn't depend on the
# exact 8000-char limit. Chunk 1 is plain prose; chunk 2 has
# the markdown markers. Without the fix, chunk 1 went out as
# ``msg_type=text``.
first_chunk = "Here is a short intro that has no markdown markers at all."
second_chunk = "## Heading\nAnd then some **bold** text."
with patch.object(
adapter, "truncate_message", return_value=[first_chunk, second_chunk],
), patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
result = asyncio.run(
adapter.send(
chat_id="oc_chat",
content=first_chunk + "\n" + second_chunk,
)
)
self.assertTrue(result.success)
self.assertEqual(len(captured), 2)
msg_types = [r.request_body.msg_type for r in captured]
self.assertEqual(msg_types, ["post", "post"])
@patch.dict(os.environ, {}, clear=True)
def test_send_plain_text_message_not_upgraded_by_prefer_post(self):
"""A message with no markdown at all must still go out as plain
``msg_type=text`` — the whole-message ``prefer_post`` decision
only flips on when the formatted message matches the hint regex.
"""
from gateway.config import PlatformConfig
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
captured = []
class _MessageAPI:
def create(self, request):
captured.append(request)
return SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(
message_id=f"om_chunk_{len(captured)}",
),
)
adapter._client = SimpleNamespace(
im=SimpleNamespace(
v1=SimpleNamespace(
message=_MessageAPI(),
)
)
)
async def _direct(func, *args, **kwargs):
return func(*args, **kwargs)
with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
result = asyncio.run(
adapter.send(
chat_id="oc_chat",
content="just a plain sentence",
)
)
self.assertTrue(result.success)
self.assertEqual(len(captured), 1)
self.assertEqual(captured[0].request_body.msg_type, "text")
@patch.dict(os.environ, {}, clear=True)
def test_send_uses_post_for_inline_markdown(self):
from gateway.config import PlatformConfig
+133
View File
@@ -0,0 +1,133 @@
"""Tests for Feishu adapter outbound markdown payload construction.
Reproduces the bug tracked in hermes-agent issue #52786:
`_build_outbound_payload` was force-downgrading any message containing a
markdown pipe table to ``msg_type=text``, so Feishu clients rendered the raw
pipe-and-dash source instead of a table. Empirically current Feishu clients
render ``post``+``md`` tables natively, so the downgrade branch must be removed.
These tests guard the fix. They invoke the real adapter via the project's
plugin-loader helper so that no ``sys.path`` / ``sys.modules`` games are
needed.
"""
from __future__ import annotations
import json
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
_adapter = load_plugin_adapter("feishu")
def _call_build_outbound_payload(content: str) -> tuple[str, str]:
"""Invoke ``_build_outbound_payload`` on a bare adapter instance.
``_build_outbound_payload`` is a method that only uses module-level
helpers (``_MARKDOWN_TABLE_RE``, ``_MARKDOWN_HINT_RE``,
``_build_markdown_post_payload``) and never touches ``self.*``, so a bare
object is sufficient.
"""
inst = object.__new__(_adapter.FeishuAdapter)
return inst._build_outbound_payload(content)
def _md_texts_from_post_payload(payload_str: str) -> list[str]:
"""Pull every ``{tag:'md', text:'...'}`` element out of a Feishu post payload.
Real payload shape::
{"zh_cn": {"content": [[{"tag": "md", "text": "..."}], ...]}}
Helpers and tests need to introspect the ``md`` blocks regardless of
locale, so we walk the structure generically.
"""
payload = json.loads(payload_str)
if not isinstance(payload, dict):
return []
texts: list[str] = []
for lang_val in payload.values():
if not isinstance(lang_val, dict):
continue
content = lang_val.get("content", [])
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, list):
candidates = block
else:
candidates = [block]
for el in candidates:
if isinstance(el, dict) and el.get("tag") == "md":
texts.append(el.get("text", ""))
return texts
def test_markdown_table_uses_post_not_text():
"""Regression test for issue #52786 (and its older sibling #23938).
A message whose only markdown is a table must take the ``post`` path,
not be downgraded to plain text.
"""
content = (
"| col A | col B |\n"
"| ----- | ----- |\n"
"| 1 | 2 |"
)
msg_type, payload_str = _call_build_outbound_payload(content)
assert msg_type == "post", (
f"expected 'post' for a markdown table (issue #52786), got {msg_type!r}; "
"the table-downgrade branch in _build_outbound_payload has been re-introduced"
)
md_texts = _md_texts_from_post_payload(payload_str)
assert md_texts, f"post payload must include at least one md element; got {payload_str!r}"
joined = "".join(md_texts)
assert "col A" in joined and "|" in joined, (
"table text was lost or reformatted when switching from text to post"
)
def test_plain_text_without_markdown_still_uses_text():
"""Negative control: a message with no markdown hints and no table must
still go to plain text. Guards against accidentally promoting everything
to ``post``."""
msg_type, _ = _call_build_outbound_payload("just a plain sentence with no markup")
assert msg_type == "text"
def test_existing_markdown_heading_still_uses_post():
"""Sanity: the existing ``post`` path (heading / list / code / bold /
link) must still work after the table downgrade is removed."""
msg_type, payload_str = _call_build_outbound_payload("# hello world\n")
assert msg_type == "post"
md_texts = _md_texts_from_post_payload(payload_str)
assert md_texts, f"expected at least one md element; got {payload_str!r}"
assert any("hello world" in t for t in md_texts), (
f"expected 'hello world' in md elements; got {md_texts!r}"
)
def test_table_combined_with_other_markdown_does_not_downgrade():
"""A message that mixes a table with surrounding markdown must also
take the ``post`` path.
The old ``_MARKDOWN_TABLE_RE`` branch returned ``text`` unconditionally
and stripped all the surrounding markdown formatting, so a Feishu
reader saw literal pipes and lost the prose framing the table.
"""
content = (
"Here is the data:\n\n"
"| col A | col B |\n"
"| ----- | ----- |\n"
"| 1 | 2 |\n\n"
"Let me know."
)
msg_type, payload_str = _call_build_outbound_payload(content)
assert msg_type == "post"
md_texts = _md_texts_from_post_payload(payload_str)
joined = "\n".join(md_texts)
assert "Here is the data" in joined, (
"leading prose was lost when downgrading a mixed-table message"
)
assert "col A" in joined, "table header was lost"
assert "Let me know" in joined, "trailing prose was lost"
+4 -1
View File
@@ -1033,7 +1033,10 @@ class TestMatrixRenderingPayloads:
@pytest.mark.asyncio
async def test_long_response_split_preserves_thread_context(self):
long_text = "Intro\n```python\n" + ("print('hello')\n" * 500) + "```\nDone"
# Build a payload guaranteed to exceed the adapter's outbound chunk
# size (configurable since #53026) so send() must split it.
repeats = (self.adapter.max_message_length // 15) + 200
long_text = "Intro\n```python\n" + ("print('hello')\n" * repeats) + "```\nDone"
result = await self.adapter.send(
"!room:example.org",
@@ -0,0 +1,85 @@
"""Tests for Matrix outbound message length configuration (#53026)."""
import asyncio
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import PlatformConfig
def _make_adapter(**extra):
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
token="syt_test_token",
extra={
"homeserver": "https://matrix.example.org",
"user_id": "@bot:example.org",
**extra,
},
)
return MatrixAdapter(config)
class TestMatrixMaxMessageLength:
def test_default_limit_is_16000(self):
adapter = _make_adapter()
assert adapter.max_message_length == 16000
assert adapter._split_threshold == 15900
def test_extra_override(self):
adapter = _make_adapter(max_message_length=12000)
assert adapter.max_message_length == 12000
assert adapter._split_threshold == 11900
def test_env_override(self, monkeypatch):
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000")
adapter = _make_adapter()
assert adapter.max_message_length == 20000
def test_extra_beats_env(self, monkeypatch):
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000")
adapter = _make_adapter(max_message_length=10000)
assert adapter.max_message_length == 10000
def test_invalid_values_fall_back_to_default(self, monkeypatch):
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "not-a-number")
adapter = _make_adapter()
assert adapter.max_message_length == 16000
def test_values_are_clamped(self):
adapter = _make_adapter(max_message_length=100)
assert adapter.max_message_length == 500
adapter = _make_adapter(max_message_length=999999)
assert adapter.max_message_length == 65535
def test_apply_yaml_config_sets_env(self, monkeypatch):
from plugins.platforms.matrix.adapter import _apply_yaml_config
monkeypatch.delenv("MATRIX_MAX_MESSAGE_LENGTH", raising=False)
_apply_yaml_config({}, {"max_message_length": 12000})
assert os.getenv("MATRIX_MAX_MESSAGE_LENGTH") == "12000"
def test_register_uses_default_limit(self):
from plugins.platforms.matrix.adapter import DEFAULT_MAX_MESSAGE_LENGTH, register
ctx = MagicMock()
register(ctx)
kwargs = ctx.register_platform.call_args[1]
assert kwargs["max_message_length"] == DEFAULT_MAX_MESSAGE_LENGTH
def test_send_uses_configured_limit(self):
adapter = _make_adapter(max_message_length=5000)
adapter._client = MagicMock()
adapter._client.send_message_event = AsyncMock(return_value="evt")
long_text = "x" * 12000
async def _run():
with patch.object(adapter, "truncate_message", wraps=adapter.truncate_message) as trunc:
await adapter.send("!room:example.org", long_text)
trunc.assert_called_once()
assert trunc.call_args[0][1] == 5000
asyncio.run(_run())
+2 -2
View File
@@ -278,9 +278,9 @@ class TestMatrixTextBatching:
@pytest.mark.asyncio
async def test_adaptive_delay_for_near_limit_chunk(self):
"""Chunks near the 4000-char limit should trigger longer delay."""
"""Chunks near the outbound limit should trigger longer delay."""
adapter = _make_matrix_adapter()
long_text = "x" * 3950
long_text = "x" * (adapter._split_threshold + 50)
adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))
await asyncio.sleep(0.15)
@@ -0,0 +1,176 @@
"""Test that kimi-coding and kimi-coding-cn both appear in the /model picker.
Both providers share the same models.dev ID (kimi-for-coding) but are distinct
profiles with different API keys, base URLs, and endpoints. The /model picker
must show both so users can pick the right endpoint for their key type.
Regression: the original ``seen_mdev_ids`` dedup by mdev_id alone would skip
kimi-coding-cn after kimi-coding was emitted because both map to
``kimi-for-coding`` (#10526). The fix deduplicates by
``(mdev_id, canonical_profile_name)`` instead, allowing distinct profiles
through.
"""
import os
from unittest.mock import patch
from hermes_cli.model_switch import (
list_authenticated_providers,
parse_model_flags,
switch_model,
)
from hermes_cli.providers import resolve_provider_full
# -- Only KIMI_CN_API_KEY set ------------------------------------------------
@patch.dict(os.environ, {"KIMI_CN_API_KEY": "sk-cn-fake"}, clear=False)
def test_kimi_cn_appears_when_only_cn_key_set():
"""kimi-coding-cn should appear when only KIMI_CN_API_KEY is set."""
providers = list_authenticated_providers(current_provider="kimi-coding-cn")
# kimi-coding-cn must be listed (it has credentials)
cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
assert cn is not None, (
"kimi-coding-cn should appear when KIMI_CN_API_KEY is set"
)
assert cn["is_current"] is True
assert cn["total_models"] > 0
# kimi-coding must NOT appear (no KIMI_API_KEY)
intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
assert intl is None, (
"kimi-coding should NOT appear when only KIMI_CN_API_KEY is set"
)
# -- Only KIMI_API_KEY set ---------------------------------------------------
@patch.dict(os.environ, {"KIMI_API_KEY": "sk-intl-fake"}, clear=False)
def test_kimi_intl_appears_when_only_intl_key_set():
"""kimi-coding (international) should appear when only KIMI_API_KEY is set."""
providers = list_authenticated_providers(current_provider="kimi-coding")
intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
assert intl is not None, (
"kimi-coding should appear when KIMI_API_KEY is set"
)
assert intl["is_current"] is True
# kimi-coding-cn must NOT appear (no KIMI_CN_API_KEY)
cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
assert cn is None, (
"kimi-coding-cn should NOT appear when only KIMI_API_KEY is set"
)
# -- Both keys set -----------------------------------------------------------
@patch.dict(os.environ, {
"KIMI_API_KEY": "sk-intl-fake",
"KIMI_CN_API_KEY": "sk-cn-fake",
}, clear=False)
def test_both_kimi_providers_appear_when_both_keys_set():
"""Both kimi-coding and kimi-coding-cn should appear when both keys set.
They are distinct profiles with different env vars and endpoints. The
existing aliases (kimi, moonshot → kimi-coding; kimi-cn, moonshot-cn →
kimi-coding-cn) must NOT create additional rows.
"""
providers = list_authenticated_providers(current_provider="kimi-coding")
# Both profile slugs must appear
intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
assert intl is not None, "kimi-coding should appear when KIMI_API_KEY is set"
assert intl["is_current"] is True
cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
assert cn is not None, (
"kimi-coding-cn should appear when KIMI_CN_API_KEY is set"
)
assert cn["is_current"] is False # `current_provider` is kimi-coding
# Exactly 2 Kimi entries — no duplicates for aliases (kimi, moonshot,
# moonshot-cn, kimi-cn)
kimi_slugs = [p["slug"] for p in providers if "kimi" in p["slug"] or "moonshot" in p["slug"]]
assert len(kimi_slugs) == 2, (
f"Expected exactly 2 Kimi entries (kimi-coding, kimi-coding-cn), "
f"got {kimi_slugs}"
)
# -- Both aliases deduped correctly ------------------------------------------
@patch.dict(os.environ, {
"KIMI_API_KEY": "sk-intl-fake",
"KIMI_CN_API_KEY": "sk-cn-fake",
}, clear=False)
def test_kimi_aliases_not_listed_separately():
"""Alias hermes_ids (kimi, moonshot) must NOT create phantom picker rows.
They resolve to the same canonical profile (kimi-coding) and should be
deduped. Only the canonical slug (kimi-coding) should appear.
"""
providers = list_authenticated_providers(current_provider="kimi-coding-cn")
slugs = {p["slug"] for p in providers}
# These alias slugs must NOT appear
for bad_slug in ("kimi", "moonshot", "moonshot-cn", "kimi-cn"):
assert bad_slug not in slugs, (
f"Alias slug '{bad_slug}' must not appear in picker (resolved to "
f"canonical profile)"
)
@patch.dict(os.environ, {
"KIMI_API_KEY": "sk-intl-fake",
"KIMI_CN_API_KEY": "sk-cn-fake",
}, clear=False)
def test_resolve_provider_full_preserves_kimi_cn_provider_identity():
"""Explicit kimi-coding-cn must not collapse to shared models.dev alias.
Regression: resolve_provider_full('kimi-coding-cn') used normalize_provider(),
which mapped both kimi-coding and kimi-coding-cn to the models.dev alias
'kimi-for-coding'. That silently rewired CN users to the international
endpoint and KIMI_API_KEY.
"""
pdef = resolve_provider_full("kimi-coding-cn", None, None)
assert pdef is not None
assert pdef.id == "kimi-coding-cn"
assert pdef.base_url == "https://api.moonshot.cn/v1"
assert pdef.api_key_env_vars == ("KIMI_CN_API_KEY",)
@patch.dict(os.environ, {
"KIMI_API_KEY": "sk-intl-fake",
"KIMI_CN_API_KEY": "sk-cn-fake",
}, clear=False)
def test_switch_model_with_explicit_kimi_cn_provider_stays_on_cn_endpoint():
"""/model ... --provider kimi-coding-cn must stay on moonshot.cn.
This hits the real switch path used by gateway /model: parse flags first,
then call switch_model() with explicit_provider. The result must not rewrite
the target provider/base_url back to the international Kimi endpoint.
"""
model_input, explicit_provider, *_ = parse_model_flags(
"kimi-k2.6 —provider kimi-coding-cn"
)
result = switch_model(
raw_input=model_input,
current_provider="deepseek",
current_model="deepseek-v4-flash",
current_base_url="https://api.deepseek.com/v1",
current_api_key="***",
is_global=False,
explicit_provider=explicit_provider,
user_providers={},
custom_providers=None,
)
assert result.success is True
assert result.target_provider == "kimi-coding-cn"
assert result.new_model == "kimi-k2.6"
assert result.base_url == "https://api.moonshot.cn/v1"
assert result.api_key == "sk-cn-fake"
@@ -299,3 +299,103 @@ def test_current_custom_endpoint_passthrough_marks_current_row(monkeypatch):
assert row["slug"] == "custom:ollama"
assert row["is_current"] is True
assert row["models"] == ["glm-5.1", "qwen3"]
# ---------------------------------------------------------------------------
# list_authenticated_providers: alias/canonical de-dup for Kimi (#49439)
# ---------------------------------------------------------------------------
#
# A single Kimi credential used to surface TWO picker rows: the alias slug
# "kimi" (emitted by the PROVIDER_TO_MODELS_DEV pass) plus its canonical
# "kimi-coding" (re-emitted by the CANONICAL_PROVIDERS cross-check pass),
# both backed by the same kimi-for-coding models.dev provider. The picker
# must list each authenticated credential exactly once, under the CANONICAL
# slug ("kimi-coding") — matching list_authenticated_providers' other alias
# rows and the overlay slug-resolution contract (see
# test_overlay_slug_resolution.py).
def _stub_kimi_discovery(monkeypatch, *, canonical):
"""Isolate list_authenticated_providers to the Kimi alias family.
Restricts the models.dev map / catalog / overlays / canonical list to
just the Kimi entries and stubs the model-id fetch so discovery stays
offline and deterministic. ``canonical`` is the CANONICAL_PROVIDERS list
the 2b cross-check pass should iterate.
"""
import agent.models_dev as md
import hermes_cli.models as hm
kimi_map = {
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
"moonshot": "kimi-for-coding",
"kimi-coding-cn": "kimi-for-coding",
}
monkeypatch.setattr(md, "PROVIDER_TO_MODELS_DEV", kimi_map)
monkeypatch.setattr(
md, "fetch_models_dev",
lambda *a, **k: {
"kimi-for-coding": {"name": "Kimi For Coding", "env": ["KIMI_API_KEY"]},
},
)
class _PInfo:
name = "Kimi For Coding"
monkeypatch.setattr(md, "get_provider_info", lambda _pid: _PInfo())
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
monkeypatch.setattr(hm, "CANONICAL_PROVIDERS", canonical)
monkeypatch.setattr(hm, "cached_provider_model_ids",
lambda *a, **k: ["kimi-k2.6", "kimi-k2.5"])
monkeypatch.setattr(hm, "clear_provider_models_cache", lambda *a, **k: None)
def test_single_kimi_credential_yields_one_canonical_row(monkeypatch):
"""One Kimi key yields a single row under the canonical 'kimi-coding' slug."""
import hermes_cli.models as hm
_stub_kimi_discovery(
monkeypatch,
canonical=[hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc")],
)
monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi")
rows = model_switch.list_authenticated_providers(max_models=10)
slugs = [r["slug"] for r in rows]
# Exactly one Kimi / kimi-for-coding-backed row, under the canonical slug —
# not both the alias ("kimi") and its canonical ("kimi-coding").
kimi_rows = [s for s in slugs if s in {"kimi", "kimi-coding"}]
assert kimi_rows == ["kimi-coding"], (
f"expected a single canonical Kimi row, got: {slugs}"
)
assert slugs.count("kimi-coding") == 1
assert "kimi" not in slugs
def test_distinct_kimi_china_credential_still_listed(monkeypatch):
"""A separate China (kimi-coding-cn) credential remains its own row.
Negative-control guard: the de-dup must collapse only the alias/canonical
pair that share a credential, not legitimately distinct providers.
"""
import hermes_cli.models as hm
_stub_kimi_discovery(
monkeypatch,
canonical=[
hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc"),
hm.ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "desc"),
],
)
monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi")
monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test-kimi-cn")
rows = model_switch.list_authenticated_providers(max_models=10)
slugs = [r["slug"] for r in rows]
assert "kimi-coding" in slugs # canonical global row
assert slugs.count("kimi-coding") == 1
assert "kimi" not in slugs # alias collapsed into the canonical row
assert "kimi-coding-cn" in slugs # distinct China endpoint preserved
@@ -97,14 +97,15 @@ class TestProviderModelIdsPreferred:
assert "claude-opus-4-7" in out
assert "kimi-k2.6" in out
def test_kimi_coding_offline_catalog_includes_k2_7_code(self):
"""Native Kimi users must see the newest Code model without live catalog help."""
def test_kimi_coding_offline_catalog_includes_k3(self):
"""Native Kimi users must see the newest models without live catalog help."""
assert "kimi-coding" not in _MODELS_DEV_PREFERRED
with patch("agent.models_dev.list_agentic_models", return_value=[]):
out = provider_model_ids("kimi-coding")
assert "kimi-k3" in out
assert "kimi-k2.7-code" in out
def test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code(self):
def test_kimi_coding_live_catalog_does_not_hide_curated_k3(self):
"""Kimi /models can lag inference; live results must not replace curated."""
with (
patch(
@@ -114,8 +115,8 @@ class TestProviderModelIdsPreferred:
patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]),
):
out = provider_model_ids("kimi-coding")
# Curated-first order; curated newest (k2.7-code) stays ahead of live.
assert out[:2] == ["kimi-k2.7-code", "kimi-k2.6"]
# Curated-first order; curated newest (k3) stays ahead of live.
assert out[:3] == ["kimi-k3", "kimi-k2.7-code", "kimi-k2.6"]
def test_k3_live_discovery_is_scoped_to_kimi_coding_endpoint(self):
"""Coding keys discover K3; legacy Moonshot keys must not advertise it."""
@@ -171,7 +172,7 @@ class TestProviderModelIdsPreferred:
custom_models = provider_model_ids("kimi-coding")
assert "k3" in coding_models
assert coding_models[0] == "kimi-k2.7-code"
assert coding_models[0] == "kimi-k3"
assert all(model.lower() != "k3" for model in legacy_models)
assert all(model.lower() != "k3" for model in custom_models)
@@ -194,7 +195,7 @@ class TestProviderModelIdsPreferred:
_model_flow_kimi({}, current_model="")
assert captured["models"] == _PROVIDER_MODELS["kimi-coding"]
assert captured["models"][0] == "kimi-k2.7-code"
assert captured["models"][0] == "kimi-k3"
class TestOpenRouterAndNousUnchanged:
+5 -1
View File
@@ -1684,7 +1684,11 @@ def test_launch_tui_worktree_validates_relative_python_against_final_cwd(
relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name
python_path = worktree / relative_python
python_path.parent.mkdir(parents=True)
os.link(sys.executable, python_path)
# copy2, not os.link: tmp_path may sit on a different filesystem than
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
import shutil
shutil.copy2(sys.executable, python_path)
captured = {}
monkeypatch.setenv("HERMES_CWD", str(parent_cwd))
+18 -2
View File
@@ -3989,6 +3989,18 @@ class TestWebServerEndpoints:
assert "api_key" not in out
assert "api_mode" not in out
# switching providers when the stale secret lives under the legacy
# ``api`` alias only (no api_key) → it must be cleared too. The resolver
# reads ``model.api`` as a key, so leaving it behind keeps a secret in
# config.yaml that contaminates the next custom resolution.
out = _apply_main_model_assignment(
{"provider": "custom", "api": "sk-legacy-stale", "base_url": "http://endpoint-a/v1"},
"openrouter",
"m",
)
assert "api" not in out
assert "api_key" not in out
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
"""Model discovery must tolerate the common /v1/models shapes and
never raise (so a slightly non-standard local endpoint still works)."""
@@ -8418,7 +8430,9 @@ class TestPtyWebSocket:
relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name
python_path = tmp_path / relative_python
python_path.parent.mkdir(parents=True)
os.link(sys.executable, python_path)
# copy2, not os.link: tmp_path may sit on a different filesystem than
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
shutil.copy2(sys.executable, python_path)
monkeypatch.setenv("HERMES_CWD", str(tmp_path))
monkeypatch.setenv("HERMES_PYTHON", str(relative_python))
monkeypatch.setattr(
@@ -8440,7 +8454,9 @@ class TestPtyWebSocket:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
executable = bin_dir / command
os.link(sys.executable, executable)
# copy2, not os.link: tmp_path may sit on a different filesystem than
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
shutil.copy2(sys.executable, executable)
env = {
"HERMES_CWD": str(tmp_path),
"HERMES_PYTHON": command,
+51
View File
@@ -1365,6 +1365,57 @@ class TestMessageStorage:
assert model_history == model_expected
assert display_history == display_expected
def test_get_ancestor_display_prefix_single_session_returns_empty(self, db):
"""A session with no compression ancestors has an empty prefix."""
db.create_session("solo", "cli")
db.append_message("solo", role="user", content="hi")
db.append_message("solo", role="assistant", content="hello")
assert db.get_ancestor_display_prefix("solo") == []
def test_get_ancestor_display_prefix_returns_ancestor_only_messages(self, db):
"""The prefix contains ONLY ancestor messages, not tip messages.
Previously the prefix was calculated as
display_history[:len(display) - len(raw)], which overcounts when
repair_message_sequence removes messages from the MIDDLE of the
tip history — the length difference includes both ancestor messages
AND repair-removed tip messages, but the slice captures the first N
display messages (tip messages when there are no ancestors),
causing duplication in _live_session_payload. (#65919)
"""
db.create_session("root", "tui")
db.append_message("root", role="user", content="ancestor prompt")
db.append_message("root", role="assistant", content="ancestor reply")
db.create_session("child", "tui", parent_session_id="root")
db.append_message("child", role="user", content="tip prompt")
db.append_message("child", role="assistant", content="tip reply")
# A verification candidate that repair_message_sequence collapses
# (consecutive-assistant merge replaces it with the next assistant).
db.append_message(
"child",
role="assistant",
content="verification candidate",
finish_reason="verification_required",
)
db.append_message("child", role="assistant", content="post-verification reply")
prefix = db.get_ancestor_display_prefix("child")
# Only the ancestor messages, not any tip messages.
assert len(prefix) == 2
assert prefix[0]["role"] == "user"
assert prefix[0]["content"] == "ancestor prompt"
assert prefix[1]["role"] == "assistant"
assert prefix[1]["content"] == "ancestor reply"
# The old broken calculation would produce a non-empty prefix
# (because repair collapses the verification candidate, making
# len(display) > len(raw)), even though there are 2 ancestor
# messages — it would overcount.
raw, display = db.get_resume_conversations("child")
old_prefix_len = max(0, len(display) - len(raw))
assert len(prefix) <= old_prefix_len
def test_finish_reason_stored(self, db):
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="assistant", content="Done", finish_reason="stop")
+12
View File
@@ -1418,6 +1418,9 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
captured.setdefault("history_calls", []).append((target, include_ancestors))
return (
@@ -1555,6 +1558,9 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
return [{"role": "user", "content": "hello"}]
@@ -1621,6 +1627,9 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False):
return [{"role": "user", "content": "hello"}]
@@ -5678,6 +5687,9 @@ def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False):
assert key == "session-key"
assert include_ancestors is True
+33
View File
@@ -609,3 +609,36 @@ class TestMasterCredentialStoresAreNeverMountable:
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
assert register_credential_file("../../.ssh/id_rsa") is False
assert register_credential_file("/etc/passwd") is False
def test_missing_guard_fails_closed_with_error_log(self, tmp_path, caplog):
"""If agent.file_safety can't be imported the mount is refused loudly.
The fail-closed path must be observable (#67665): a silent deny with
no diagnostic reproduces the trust gap the deny-list was added to fix.
"""
import tools.credential_files as cf
home = self._home(tmp_path)
with patch.dict(os.environ, {"HERMES_HOME": str(home)}), \
patch.object(cf, "get_read_block_error", None):
with caplog.at_level("ERROR", logger="tools.credential_files"):
assert cf.register_credential_file("google_token.json") is False
assert cf.get_credential_file_mounts() == []
assert any("deny-list cannot be consulted" in r.message for r in caplog.records)
def test_guard_exception_fails_closed_with_traceback(self, tmp_path, caplog):
"""A raising guard refuses the mount and logs the stack trace."""
import tools.credential_files as cf
home = self._home(tmp_path)
def _boom(path):
raise RuntimeError("guard exploded")
with patch.dict(os.environ, {"HERMES_HOME": str(home)}), \
patch.object(cf, "get_read_block_error", _boom):
with caplog.at_level("ERROR", logger="tools.credential_files"):
assert cf.register_credential_file("google_token.json") is False
assert cf.get_credential_file_mounts() == []
rec = next(r for r in caplog.records if "read guard raised" in r.message)
assert rec.exc_info is not None, "traceback must be attached (logger.exception)"
+24
View File
@@ -352,6 +352,9 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
@@ -418,6 +421,9 @@ def test_session_resume_defaults_to_deferred_build(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
@@ -565,6 +571,9 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [multimodal_user, text_only_assistant]
@@ -621,6 +630,9 @@ def test_session_resume_lazy_registers_watch_session_without_agent(server, monke
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "delegated goal"},
@@ -700,6 +712,9 @@ def test_session_resume_lazy_reports_running_for_inflight_child(server, monkeypa
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [{"role": "user", "content": "delegated goal"}]
@@ -757,6 +772,9 @@ def test_session_resume_lazy_tolerates_missing_row_for_active_child(server, monk
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
# No rows for an unwritten session.
return []
@@ -860,6 +878,9 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
@@ -1083,6 +1104,9 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server,
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return list(ancestor_history)
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
if include_ancestors:
return ancestor_history + current_history

Some files were not shown because too many files have changed in this diff Show More