Compare commits

..
Author SHA1 Message Date
alt-glitch 774ecf93dc cli: worktree lock + dirty-tree preservation — stop pruning uncommitted work
Three behavior changes to the hermes -w worktree lifecycle:

1. Git-native locks. _setup_worktree now locks its worktree
   (git worktree lock --reason "hermes session pid=<pid>"), and
   _prune_stale_worktrees skips locked worktrees at ANY age — a lock
   from a live or crashed session means "do not touch". New helpers
   _lock_worktree / _unlock_worktree / _worktree_is_locked (fail-safe:
   any error reads as locked) / _worktree_is_dirty (fail-safe: any
   error reads as dirty).

2. Dirty trees are preserved. _cleanup_worktree previously destroyed
   worktrees with uncommitted changes if there were no unpushed
   commits; it now keeps the worktree, branch, and lock when the tree
   is dirty OR has unpushed commits, and prints manual cleanup hints
   (git worktree unlock + remove --force). The >72h "force remove
   regardless" prune tier is removed: pruning may only ever delete
   clean, unlocked, fully-pushed worktrees.

3. Branch deletion is gated on removal success. Both cleanup and
   prune previously deleted the branch without checking the
   git worktree remove returncode, dropping easy reachability of the
   commits even when removal failed; the branch is now only deleted
   after a successful remove.
2026-06-16 19:35:25 +05:30
Teknium a68ac0c49a feat(desktop): allow /browser connect on a local gateway (#47245)
* fix(skills): guard recursive skill delete against tree-escape

Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.

* feat(desktop): allow /browser connect on a local gateway

/browser was hardcoded as terminal-only in the desktop slash palette, so
the chat GUI rejected it with "only available in the terminal interface."
The TUI already drives the live CDP connection via the browser.manage RPC.

Wire the same RPC into the desktop dispatcher as a /browser action handler,
gated to local-gateway connections ($connection.mode !== 'remote'). connect
mutates BROWSER_CDP_URL (and may launch Chrome) in the gateway process, so
it's only meaningful when that process runs on this machine; a remote
gateway gets a clear "local gateway only" message instead.
2026-06-16 09:03:43 -05:00
Wolfram Ravenwolf 16fc717091 fix(mattermost): harden delivery hygiene
PROBLEM: Mattermost threads can become invalid or enormous, exposing two failure modes: internal scratch/reasoning/commentary displays could leak into persistent Mattermost threads via global display toggles, while rejected threaded user-visible replies could disappear unless every failed send fell back flat. A broad flat fallback would pollute channels with tool/status/progress noise.

SOLUTION: Require explicit Mattermost platform opt-in for scratch displays, keep using the existing notify=True metadata marker for user-visible final text/media/file replies, and allow the Mattermost plugin adapter to flat-fallback only notify-worthy sends whose threaded POST failure looks like a broken root/thread. Keep tool/status/progress and other non-notify sends thread-strict. Add regression tests for display opt-in, notify-only broken-thread fallback, generic API failure suppression, and stream notify metadata.

Verification: tests/gateway/test_mattermost.py tests/gateway/test_stream_consumer.py tests/gateway/test_stream_consumer_thread_routing.py tests/gateway/test_stream_consumer_fresh_final.py tests/gateway/test_stream_consumer_draft.py; tests/gateway/test_session_api.py tests/gateway/test_status_command.py tests/gateway/test_resume_command.py tests/hermes_cli/test_commands.py; py_compile touched gateway files; git diff --check.

Session: Mattermost thread 6qg8e9dd1pd9pkhi74xyaa1mry, 2026-06-01.
2026-06-16 06:34:54 -07:00
teknium1 925b0d1ab5 chore: add zimigit2020 to release AUTHOR_MAP 2026-06-16 06:23:53 -07:00
Rory EvansandClaude Opus 4.8 e65d74bc6f fix(gateway): accept metadata kwarg in WhatsApp/email send_image
`BasePlatformAdapter.send_multiple_images` passes `metadata=metadata` to
`send_image` / `send_image_file` / `send_animation` on every send. The
WhatsApp and email `send_image` overrides stopped their signature at
`reply_to`, so any image delivered as a URL (the common case — image-gen
backends return URLs) raised:

    TypeError: send_image() got an unexpected keyword argument "metadata"

and the image silently failed to send. Their sibling overrides
(`send_image_file` / `send_video` / `send_voice` / `send_document`)
already absorb it via **kwargs, which is why only plain image-URL sends
broke.

- whatsapp/email `send_image`: accept `metadata` (matches the base
  signature); WhatsApp forwards it to the super() text fallback.
- Add `tests/gateway/test_media_metadata_contract.py`: asserts WhatsApp +
  email accept it, plus a best-effort sweep over every adapter so the next
  slip fails at test time instead of in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:23:53 -07:00
Teknium 4858942c55 fix(auxiliary): honor main fallback chain for auto tasks (#47235) 2026-06-16 06:23:24 -07:00
Teknium 4d470b3dbb fix(slack): route /debug via /hermes to restore Telegram-parity (#47248)
Slack caps apps at 50 slash commands and the registry is at that ceiling, so
adding /debug clamped it out of the native list and broke the telegram-parity
test (debug on Telegram, absent from Slack native slashes, in neither
exclusion set). Add 'debug' to _SLACK_VIA_HERMES_ONLY — same treatment credits
already gets. /debug stays native on CLI/TUI/Telegram/Discord and reachable via
/hermes debug on Slack.
2026-06-16 06:20:01 -07:00
Teknium 2483200963 test(tui): isolate session-create no-race test from shard-sibling leakage (#47230)
test_session_create_no_race_keeps_worker_alive flaked on CI shard 3 with
'build thread unregistered its own notify despite no race' while passing
20/20 in isolation locally. Root cause: daemon build threads from sibling
session.create tests in the same shard process mutate the shared
server._sessions dict under _sessions_lock and can replace/pop entries
mid-run, flipping this build thread's 'replaced' check (server.py:1011) to
True and triggering a spurious unregister_gateway_notify.

Fix is test-only: snapshot + clear server._sessions before the request so
the test sees only its own session, restore siblings in finally. Also assert
agent_ready.wait() actually returned True (was silently ignoring timeout) and
bump the timeout 2s -> 10s for loaded CI runners.
2026-06-16 05:56:50 -07:00
teknium1 1ac76a9472 chore: add MrDiamondBallz to release AUTHOR_MAP 2026-06-16 05:56:11 -07:00
MrDiamondBallz 9a59ad73dd fix(auth): preserve Codex pool-only rate-limit state
Classify exhausted pool-only openai-codex credentials as quota/rate-limited instead of missing auth. This prevents auth status and runtime credential resolution from reporting missing credentials when a valid manual:device_code pool credential exists but is temporarily in a 429 usage-limit cooldown.

Adds regression coverage for pool-only Codex auth status and runtime resolution.
2026-06-16 05:56:11 -07:00
teknium 6373aba80f feat(gateway): rename to tool_progress_grouping, add config/docs/tests
Follow-up to salvaged PR #41620:
- Rename tool_progress_style -> tool_progress_grouping (clearer intent)
- Add display.tool_progress_grouping to DEFAULT_CONFIG (accumulate default)
- Document in messaging docs incl. 'separate is noisier, only where progress enabled'
- Add resolver tests (default/global/override/invalid/case)
2026-06-16 05:49:24 -07:00
Wolfram Ravenwolf fc956b9db6 feat: add tool_progress_style config (accumulate vs separate)
Add display.tool_progress_style setting to control how tool progress
messages are displayed in chat platforms:

- 'accumulate' (default): Edit a single message with all tool calls
  (new v0.9.0 behavior)
- 'separate': Send each tool call as its own message, interleaved
  with thinking messages (pre-v0.9 behavior, better readability)

The setting participates in the per-platform display override system
and can be set globally or per-platform.

Files: gateway/display_config.py, gateway/run.py
2026-06-16 05:49:24 -07:00
teknium 98ae28657f feat(display): document and test memory_notifications setting
Follow-up to salvaged PR #4684:
- Add display.memory_notifications to DEFAULT_CONFIG (off|on|verbose, default on)
- Document the setting in docs/user-guide/features/memory.md
- Add resolver tests for off/on/verbose memory + skill paths
2026-06-16 05:45:40 -07:00
Wolfram Ravenwolf 4cf9d80fba feat(display): verbose skill change notifications with content previews
When display.memory_notifications is set to 'verbose', skill_manage
notifications now show meaningful change details instead of just the
generic tool message.

Before (verbose mode):
  💾 📝 Patched SKILL.md in skill 'gogcli' (1 replacement).

After (verbose mode):
  💾 📝 Skill 'gogcli' patched: "old pitfall text..." → "new pitfall text..."

Changes:
- skill_manager_tool.py: _patch_skill() now includes old/new string
  previews (truncated to 200 chars) in the result via '_change' key.
  _create_skill() and _edit_skill() include skill description from
  frontmatter for verbose create/edit notifications.
- run_agent.py: Background review notification builder now reads the
  '_change' dict from skill tool results and formats descriptive
  notifications per action type (patch → old→new diff, create/edit →
  description preview). Falls back to generic message when _change
  data is unavailable (backwards compatible).

This is especially useful when subagents patch skills, since neither
the user nor the parent agent can see what the subagent changed.
2026-06-16 05:45:40 -07:00
Wolfram Ravenwolf 20b1f4f3fb feat(memory): configurable background memory update notifications
Background memory reviews now support three notification modes,
configured via display.memory_notifications in config.yaml:

  off     — no chat notification (still logged to stdout/HA log)
  on      — generic '💾 Memory updated' (default, unchanged behavior)
  verbose — content preview with action indicators:
            💾 Memory  Hermes Repo liegt unter /config/amy/hermes-agent/...
            💾 Memory ✏️ Updated repo path from claude-code to hermes-agent...
            💾 Memory  old entry about claude-code path...

Previews are truncated to 120 chars for adds/replaces, 60 for removes.
Each action gets its own line in verbose mode for readability.

Files: run_agent.py, gateway/run.py
2026-06-16 05:45:40 -07:00
Teknium a6364bfa08 fix(telegram): edit streamed previews in place as rich (Bot API 10.1) (#46890)
Streamed Telegram replies that finalize through editMessageText were
converted to MarkdownV2, which has no table syntax and rewrites pipe
tables into bullet lists — users saw a table while streaming that
collapsed to a list at the last moment.

Finalize now edits the existing preview IN PLACE via Bot API 10.1's
editMessageText rich_message parameter when the content has constructs
the legacy path degrades (tables, task lists, <details>, block math).
No fresh send + delete, so no duplicate-preview flicker — the reason
#46206 reverted the fresh-final re-send path. prefers_fresh_final_streaming
stays False; the in-place edit replaces it.

- _needs_rich_rendering(): rich reserved for table/task-list/details/math
  (adapted from #45995, @YonganZhang); plain replies stay on MarkdownV2.
- _try_edit_rich(): editMessageText + rich_message via do_api_request,
  mirroring _try_send_rich's fallback/latch/transient contract.
- edit_message finalize tries rich in place before the 4,096 overflow
  pre-flight (rich cap is 32,768), falling back to legacy on rejection.
- rich_messages default flipped back to True (DEFAULT_CONFIG + adapter).
- docs (en + zh-Hans) + cli-config example updated to default-on.

Closes the root cause behind #45911 / #46009.
2026-06-16 05:26:04 -07:00
5b3fa26366 fix(photon): unify project identifiers and update documentation for Spectrum provisioning
Co-Authored-By: Marvin <marvin@photon.codes>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:25:56 -07:00
brooklyn!andqingshan89 c6b0eb4de0 fix(desktop): open remote-gateway artifacts via authenticated download (#46895)
On a remote gateway connection, agent-written files live on the gateway
host, not the desktop's disk, so the Artifacts view's file:// hrefs failed
("Invalid external URL") and image thumbnails broke.

Make mediaExternalUrl() remote-aware in one place: in remote mode it
rewrites gateway-local paths to GET /api/files/download (a new endpoint
that streams the file as a Content-Disposition: attachment). The artifacts
view now resolves through it, and so do the existing chat-media and
generated-image callers, for free.

The download endpoint stays auth-gated; auth_middleware additionally
accepts the session token as a ?token= query param for this one path so a
shell/browser-opened download (which can't set the session header) still
authenticates — the same query-token tradeoff as the /api/pty WebSocket.
It is NOT added to PUBLIC_API_PATHS.

Salvages #46663 (which carried ~19k lines of CRLF noise and made the
endpoint public). Reimplemented on a clean LF base with the security hole
closed and tests added.

Co-authored-by: qingshan89 <qs2816661685@gmail.com>
2026-06-15 23:50:19 -05:00
Gille 0441b7f19f fix(desktop): route global remote profile REST calls (#47011)
* fix(desktop): route global remote profile REST calls

* fix(dashboard): scope oauth provider routes by profile

* test(tui): isolate notification poller queue
2026-06-15 23:24:55 -05:00
Shannon Sands 7cd71de1f4 Simplify dashboard update detection to containers 2026-06-15 20:08:39 -07:00
Shannon Sands b1d6a57883 Detect containerized dashboard update management 2026-06-15 20:08:39 -07:00
Shannon Sands 0b6b29a30c Hide hosted dashboard update controls 2026-06-15 20:08:39 -07:00
brooklyn! 55cb4103be Merge pull request #46951 from NousResearch/bb/new-session-window
feat(desktop): hotkey to open a new session in a compact window
2026-06-15 21:05:30 -05:00
Brooklyn Nicholson 67233d1c2a fix(desktop): sync new sessions across windows
Broadcast session-list mutations from scratch windows so the main sidebar refreshes without manual reloads.
2026-06-15 20:59:57 -05:00
Brooklyn Nicholson 0f75e9904a feat(desktop): trim scratch window chrome
Hide nonessential Hermes chrome in the new-session pop-out while preserving native window controls and stable first-message positioning.
2026-06-15 20:59:57 -05:00
Brooklyn Nicholson 98c294126b feat(desktop): open new sessions in compact windows
Add the Electron IPC bridge and rebindable shortcut for opening an unkeyed scratch window on the new-session draft.
2026-06-15 20:59:57 -05:00
Teknium 0a8f3e21b8 fix(delegation): forward background flag so delegate_task(background=true) runs async (#46968)
* fix(skills): guard recursive skill delete against tree-escape

Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.

* fix(delegation): forward background flag in delegate_task dispatch

delegate_task is an _AGENT_LOOP_TOOLS member, so every surface (CLI,
gateway, desktop/TUI) routes it through AIAgent._dispatch_delegate_task.
That forwarder passed every schema field except background, so
delegate_task(background=true) was silently downgraded to a synchronous
run and returned the sync results payload instead of a delegation_id.

The model sees background in the schema (the call validates), but the
value never reached the function. Add the one missing kwarg so async
background delegation actually engages.
2026-06-15 18:52:02 -07:00
Teknium 2dbc3bd937 fix(skills): guard recursive skill delete against tree-escape (#46929)
Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.
2026-06-15 17:14:59 -07:00
Dominik 9d2ec8d35a Merge pull request #46244 from skyc1e/fix/desktop-explorer-refresh
fix(desktop): keep file tree refresh clickable
2026-06-15 23:52:00 +00:00
brooklyn! 423d24780b Merge pull request #46909 from NousResearch/bb/coalesce-interleaved-reasoning
fix(desktop): coalesce interleaved reasoning/content stream parts
2026-06-15 18:38:24 -05:00
Brooklyn Nicholson 37d717054e refactor(desktop): unify stream-part coalescing into one helper
Collapse segmentMergeIndex + mergeTextInto + the three append helpers
into a single segment-aware appendStreamPart core plus a part-factory
table. Same behavior, DRY.
2026-06-15 18:13:52 -05:00
Brooklyn Nicholson 1cb75b7971 fix(desktop): coalesce interleaved reasoning/content stream parts
Models that interleave their reasoning_content and content token streams
(Kimi/DeepSeek/GLM-style routes) emit text -> reasoning -> text deltas
within a single tool-bounded segment. Appending each delta as its own
part shredded one sentence into "Let me" / Thinking / "verify the file",
with a Thinking disclosure wedged mid-sentence.

Coalesce streaming deltas into the most recent same-type part within the
current segment (bounded by any non-streaming part, e.g. a tool call).
The opposite streaming channel is transparent, so a reasoning burst
between two content deltas no longer opens a fresh text part, while a
real tool call still starts a new segment and preserves narration order.

Data-layer only; the renderer already groups consecutive reasoning.
2026-06-15 17:48:35 -05:00
Teknium 5bfed0fe07 feat(skills): add optional payments skills (Stripe Link, MPP, Projects) (#31343)
* feat(skills): add optional payments skills (Stripe Link, MPP, Projects)

Adds four optional skills under optional-skills/payments/ wrapping the
Stripe Link CLI, the Machine Payments Protocol (MPP) clients, and the
Stripe Projects CLI plugin. Plus a router skill (payments) that picks
between them based on user intent.

All four are gated [linux, macos] — Stripe's Link CLI does not yet
support Windows. The other CLIs (mppx, stripe projects) are
cross-platform on paper but the payments cluster moves as a unit until
Link CLI gains Windows support.

Skills:
- stripe-link-cli  - one-time virtual cards + Shared Payment Tokens
- mpp-agent        - HTTP 402 payments via mppx/Tempo/Privy/AgentCash
- stripe-projects  - provision SaaS services + credential sync
- payments         - router/index skill for the cluster

Hard invariants encoded in every skill:
- Card PANs/wallet keys never enter agent transcripts, logs, or memory
- Spend approvals are not self-bypassable (Link app / wallet UI / CLI prompt)
- Final totals confirmed with user before any --request-approval call
- Credential output files cleaned up after one-time use

Zero core touches. Skills install via:
  hermes skills install official/payments/<skill>

* chore(skills/payments): drop router skill — skills shouldn't depend on other skills

Removed optional-skills/payments/payments/ — the router skill that
existed to hand off between stripe-link-cli, mpp-agent, and
stripe-projects.

Per project convention: skills should be independently loadable; a
router is a footgun because (a) it assumes the loader will follow its
recommendation rather than just loading what the user asked for, and
(b) it duplicates the trigger logic that already lives in each
sub-skill's '## When to Use' section.

The three remaining skills declare their own triggers and routing
hints. The optional-skills catalog still groups them under '## payments',
which is the appropriate place for cluster-level discoverability.

Also drops 'payments' from each remaining skill's 'related_skills' list
and removes the corresponding entries from the docs catalog + sidebars.

* feat(skills/payments): fold in danhill-stripe review feedback

- mpp-agent: add link-cli as a client option (when Link is already set
  up, or the 402 challenge advertises method="stripe")
- stripe-link-cli: reframe Link account / payment method / approval app
  as first-run setup, not hard preconditions (CLI configures them on
  first run)
- regenerate the two affected optional-skills docs pages
2026-06-15 15:28:42 -07:00
TekniumandWolfram Ravenwolf 5a0e0d35b9 fix(mattermost): preserve thread-local delivery hygiene
Salvage the valid thread-routing pieces from #41640:
- route Mattermost progress/status sends through metadata thread IDs
- treat top-level Mattermost channel posts as thread roots for progress
- preserve thread metadata through media/file sends
- allow flat fallback only for final notify-worthy replies on confirmed broken roots

Co-authored-by: Wolfram Ravenwolf <github.com@wolfram.ravenwolf.de>
2026-06-15 15:06:23 -07:00
kshitij d2b34e89b0 Merge pull request #44431 from erosika/feat/honcho-identity-tree
feat(honcho): gateway-gated identity tree + canonicalize on pinUserPeer
2026-06-16 03:35:24 +05:30
Erosika 6dde7d4657 docs(memory-providers): cover gateway identity mapping for Honcho
The Honcho provider page documented the per-profile peer model (user
peer / AI peer / observation) but never the gateway axis — how platform
runtime IDs map to peers. Adds the three keys to the config table and a
short Gateway identity mapping subsection that points at the Honcho page
for the resolver ladder.

Uses the corrected pinUserPeer wording (pins non-agent users, overrides
aliases) so the provider-comparison reader gets the same accurate framing
as the dedicated page.
2026-06-15 21:50:24 +00:00
Erosika c7513df4f9 docs(honcho): clarify pinUserPeer pins only non-agent users
'everyone collapses to your peer' read as a promise about all traffic.
pinUserPeer pins the user-side peer and is checked before userPeerAliases
(session.py:335), so a pin overrides every alias — including agent peers.
For a multi-agent operator that silently pools distinct agents onto one
peer, the opposite of intent.

Scopes the wording to 'every non-agent gateway user', notes the pin
overrides aliases, and points agent-mesh operators at pinUserPeer:false +
userPeerAliases instead. Same correction in the wizard menu/echo text,
the plugin README, and the website Honcho page.
2026-06-15 21:34:09 +00:00
Erosika 1544813bfe chore(honcho): replace example Telegram UID with placeholder 2026-06-11 15:06:07 -04:00
Erosika 2708c33c75 docs(honcho): anonymize example peer name to alice 2026-06-11 15:04:01 -04:00
Erosika 23a7458acf docs(website): cover gateway identity mapping in Honcho feature page
The identity-mapping keys never made it to the site docs. Add the three keys
to the config reference and a Gateway Identity Mapping section: when it
applies (gateway only, setup-gated), the intent tree, resolver order, the
un-pin orphan warning, and the deprecated pinPeerName alias.
2026-06-11 14:58:19 -04:00
Erosika 99feb03607 docs(honcho): demote pinPeerName to deprecated alias; document gateway identity tree
Drop pinPeerName from the key table (now a deprecated-alias note), and replace
the single/multi/hybrid 'deployment shapes' section with the gateway-gated
intent tree the wizard actually presents, including the [e] raw-edit hatch and
the un-pin pooling steer.
2026-06-10 16:15:17 -04:00
Erosika d7dfeed6dc feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree
The single/multi/hybrid 'deployment shape' was a misnomer: these keys only
affect the gateway (the one entrypoint supplying a runtime user ID), and the
three preset names stamped a lossy taxonomy onto three orthogonal knobs while
hiding which keys got written.

Replace it with an intent-led tree gated on gateway detection:
- _gateway_platforms() lazily inspects the gateway config (best-effort, no
  hard dependency); the step auto-skips when no platform is connected.
- 'who talks to this?' → just me / me+others (pooled?) / only others, deriving
  pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result.
- [e] drops to a raw-knob editor for power users.
- The single→multi orphan guard survives as a pooling steer.
2026-06-10 16:14:24 -04:00
Erosika bb5cb32838 refactor(honcho): canonicalize identity-mapping on pinUserPeer, migrate legacy key
The setup wizard wrote the legacy pinPeerName even though pinUserPeer is
the canonical key that outranks it in the resolver — so it had to scrub
the canonical key afterward to stop it winning. Write pinUserPeer directly
and migrate any legacy pinPeerName onto it on touch (setup load + clone),
which removes the precedence-fighting entirely.

Resolver still reads pinPeerName as a back-compat alias; that's deferred.
2026-06-10 16:07:53 -04:00
117 changed files with 5486 additions and 1043 deletions
@@ -43,7 +43,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
check-attribution:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Full history needed for git log
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
+3 -3
View File
@@ -39,10 +39,10 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: hadolint
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0
with:
dockerfile: Dockerfile
config: .hadolint.yaml
@@ -54,7 +54,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: shellcheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
+11 -11
View File
@@ -54,7 +54,7 @@ jobs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
@@ -63,7 +63,7 @@ jobs:
# to gha with a per-arch scope; the push step below reuses every
# layer from this build.
- name: Build image (amd64, smoke test)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -132,7 +132,7 @@ jobs:
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -143,7 +143,7 @@ jobs:
- name: Push amd64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -188,7 +188,7 @@ jobs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
@@ -201,7 +201,7 @@ jobs:
# crashed the build before the smoke test (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -222,7 +222,7 @@ jobs:
# token failure mode cannot recur.
- name: Build image (arm64, smoke test, cache read-only PR)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -238,7 +238,7 @@ jobs:
# PR/main build starts warm.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -257,7 +257,7 @@ jobs:
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -265,7 +265,7 @@ jobs:
- name: Push arm64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -319,7 +319,7 @@ jobs:
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
check-common-ancestor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history both sides for merge-base
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need full history for merge-base + worktree
@@ -166,7 +166,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
@@ -190,7 +190,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
+4 -4
View File
@@ -32,7 +32,7 @@ jobs:
# True when the curated MCP catalog / bundled MCP manifests changed.
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Check for relevant file changes
@@ -72,7 +72,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -207,7 +207,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -286,7 +286,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
slice: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore duration cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -163,7 +163,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install ripgrep (prebuilt binary)
run: |
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
@@ -36,7 +36,7 @@ jobs:
desktop-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
@@ -145,7 +145,7 @@ jobs:
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@5b79a39c381910c090341a2c9b0bf022c8b387e1 # v3.4.0
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+1
View File
@@ -299,6 +299,7 @@ def init_agent(
# would mangle the escape sequences. None = use builtins.print.
agent._print_fn = None
agent.background_review_callback = None # Optional sync callback for gateway delivery
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
+159 -27
View File
@@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(
if not fb_provider or fb_provider.lower() == skip:
continue
fb_model = str(entry.get("model", "")).strip() or None
fb_base_url = str(entry.get("base_url", "")).strip() or None
fb_api_key = str(entry.get("api_key", "")).strip() or None
label = f"fallback_chain[{i}]({fb_provider})"
try:
fb_client = _resolve_single_provider(
fb_provider, fb_model, fb_base_url, fb_api_key)
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception:
fb_client = None
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
task, reason, failed_provider, label, fb_model or "default",
task, reason, failed_provider, label, resolved_model or fb_model or "default",
)
return fb_client, fb_model, label
return fb_client, resolved_model or fb_model, label
tried.append(label)
if tried:
@@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(
return None, None, ""
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
if explicit:
return explicit
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
"""Resolve one fallback entry through the central provider router."""
provider = str(entry.get("provider") or "").strip()
model = str(entry.get("model") or "").strip() or None
if not provider or not model:
return None, None
base_url = str(entry.get("base_url") or "").strip() or None
api_key = _fallback_entry_api_key(entry)
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
return resolve_provider_client(
provider,
model=model,
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
)
def _try_main_fallback_chain(
task: Optional[str],
failed_provider: str = "",
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try the top-level main-agent fallback chain for an auxiliary call.
``provider: auto`` auxiliary tasks should respect the user's declared
main fallback policy before dropping into Hermes' built-in discovery
chain. The top-level chain is read through ``get_fallback_chain`` so
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
participate in the same order as the main agent.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.fallback_config import get_fallback_chain
chain = get_fallback_chain(load_config())
except Exception as exc:
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
return None, None, ""
if not chain:
return None, None, ""
failed_norm = (failed_provider or "").strip().lower()
main_norm = (_read_main_provider() or "").strip().lower()
skip = {p for p in (failed_norm, main_norm, "auto") if p}
tried: List[str] = []
for i, entry in enumerate(chain):
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider") or "").strip()
fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue
fb_norm = fb_provider.lower()
label = f"fallback_providers[{i}]({fb_provider})"
if fb_norm in skip:
tried.append(f"{label} (skipped)")
continue
if _is_provider_unhealthy(fb_norm):
_log_skip_unhealthy(fb_norm, task)
tried.append(f"{label} (unhealthy)")
continue
try:
fb_client, resolved_model = _resolve_fallback_entry(entry)
except Exception as exc:
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
fb_client, resolved_model = None, None
if fb_client is not None:
logger.info(
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
task or "call", reason, failed_provider or "auto", label,
resolved_model or fb_model,
)
return fb_client, resolved_model or fb_model, fb_provider
tried.append(label)
if tried:
logger.debug(
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
task or "call", ", ".join(tried),
)
return None, None, ""
def _resolve_single_provider(
provider: str,
model: Optional[str] = None,
@@ -3116,16 +3210,19 @@ def _resolve_single_provider(
Uses the existing provider resolution infrastructure where possible.
"""
# Reuse resolve_provider_client which handles provider→client mapping
# Reuse resolve_provider_client which handles provider→client mapping.
client, resolved_model = resolve_provider_client(
provider=provider,
model=model,
base_url=base_url,
api_key=api_key,
explicit_base_url=base_url,
explicit_api_key=api_key,
)
return client
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
def _resolve_auto(
main_runtime: Optional[Dict[str, Any]] = None,
task: Optional[str] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain.
Priority:
@@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
main_provider, resolved or main_model)
return client, resolved or main_model
# ── Step 2: aggregator / fallback chain ──────────────────────────────
# ── Step 2: user-configured fallback policy ─────────────────────────
# In auto mode, respect the task-specific fallback chain first, then the
# main agent's top-level fallback_providers/fallback_model chain. The
# hardcoded provider discovery chain below is only the convenience default
# for users who have not declared a fallback policy.
if task:
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
task, main_provider or "auto", reason="main provider unavailable")
if fb_client is not None:
return fb_client, fb_model
# ── Step 3: aggregator / fallback chain ──────────────────────────────
tried = []
for label, try_fn in _get_provider_chain():
if _is_provider_unhealthy(label):
@@ -3344,6 +3456,7 @@ def resolve_provider_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
@@ -3464,7 +3577,7 @@ def resolve_provider_client(
# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto(main_runtime=main_runtime)
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
@@ -4357,11 +4470,16 @@ def _client_cache_key(
api_mode: Optional[str] = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> tuple:
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
# `auto` can now resolve through task-specific or main fallback policy,
# so the task participates in the cache key. Non-auto providers keep the
# old cache shape because the explicit provider/model tuple is sufficient.
task_key = (task or "") if provider == "auto" else ""
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
@@ -4554,6 +4672,7 @@ def _get_cached_client(
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.
@@ -4591,6 +4710,7 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=main_runtime,
is_vision=is_vision,
task=task,
)
with _client_cache_lock:
if cache_key in _client_cache:
@@ -4635,6 +4755,7 @@ def _get_cached_client(
api_mode=api_mode,
main_runtime=runtime,
is_vision=is_vision,
task=task,
)
if client is not None:
# For async clients, remember which loop they were created on so we
@@ -5140,7 +5261,7 @@ def call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5466,14 +5587,19 @@ def call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net)
# For auto users (no explicit aux provider), use the full
# auto-detection chain instead — its Step 1 IS the main agent
# model, so users on `auto` already get main-model fallback.
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
@@ -5636,7 +5762,7 @@ async def async_call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto", async_mode=True)
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -5904,13 +6030,19 @@ async def async_call_llm(
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. Main agent model (last-resort safety net)
# Auto users get the full auto-detection chain instead — its
# Step 1 IS the main agent model.
# 2. For auto: top-level main fallback_providers/fallback_model
# 3. For auto: built-in auxiliary discovery chain
# 4. For explicit aux providers: main agent model safety net
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
if fb_client is None:
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
+121 -19
View File
@@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = (
def summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Build the human-facing action summary for a background review pass.
Walks the review agent's session messages and collects "successful tool
action" descriptions to surface to the user (e.g. "Memory updated").
Tool messages already present in ``prior_snapshot`` are skipped so we
don't re-surface stale results from the prior conversation that the
review agent inherited via ``conversation_history`` (issue #14944).
Walks the review agent's session messages and collects successful memory
and skill-management actions to surface to the user. Tool messages already
present in ``prior_snapshot`` are skipped so stale inherited results are
not re-surfaced as fresh background work (issue #14944).
Matching is by ``tool_call_id`` when available, with a content-equality
fallback for tool messages that lack one.
``notification_mode`` controls display detail:
- ``off``: return no actions.
- ``on``: generic "Memory updated"/tool messages.
- ``verbose``: include compact content previews from tool-call arguments.
"""
mode = str(notification_mode or "on").lower()
if mode == "off":
return []
verbose = mode == "verbose"
existing_tool_call_ids = set()
existing_tool_contents = set()
for prior in prior_snapshot or []:
@@ -262,6 +269,42 @@ def summarize_background_review_actions(
if isinstance(content, str):
existing_tool_contents.add(content)
# Map review-agent tool results back to the calls that produced them. The
# result JSON only says "Entry added"; the call arguments contain action,
# target, and content previews. Restricting to notify_tools also prevents
# helper tools from surfacing as memory work just because they succeeded.
notify_tools = {"memory", "skill_manage"}
all_tool_call_ids: set = set()
call_details: dict = {}
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls", []) or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function", {}) or {}
fn_name = fn.get("name", "")
tcid = tc.get("id")
if tcid:
all_tool_call_ids.add(tcid)
if fn_name not in notify_tools:
continue
try:
args = json.loads(fn.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
args = {}
if tcid:
call_details[tcid] = {
"tool": fn_name,
"action": args.get("action", "?"),
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
}
actions: List[str] = []
for msg in review_messages or []:
if not isinstance(msg, dict) or msg.get("role") != "tool":
@@ -273,6 +316,8 @@ def summarize_background_review_actions(
content_str = msg.get("content")
if isinstance(content_str, str) and content_str in existing_tool_contents:
continue
if tcid and all_tool_call_ids and tcid not in call_details:
continue
try:
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
@@ -280,19 +325,75 @@ def summarize_background_review_actions(
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
target = data.get("target", "")
if "created" in message.lower():
actions.append(message)
elif "updated" in message.lower():
actions.append(message)
elif "added" in message.lower() or (target and "add" in message.lower()):
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "Entry added" in message:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
actions.append(f"{label} updated")
elif "removed" in message.lower() or "replaced" in message.lower():
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
message_lower = message.lower()
if not verbose:
if "created" in message_lower:
actions.append(message)
continue
if "updated" in message_lower:
actions.append(message)
continue
if is_skill and "patched" in message_lower:
actions.append(message)
continue
if is_skill:
label = "Skill"
elif target:
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
else:
continue
if verbose:
action = detail.get("action", "")
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
max_preview = 120
if is_skill:
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
"" if len(old_string) > 80 else ""
)
new_preview = new_string[:80].replace("\n", " ") + (
"" if len(new_string) > 80 else ""
)
actions.append(
f"📝 Skill '{skill_name}' patched: "
f"\"{old_preview}\"\"{new_preview}\""
)
elif action == "create" and description:
actions.append(f"📝 Skill '{skill_name}' created: {description}")
elif action == "edit" and description:
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
elif action == "replace" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif action == "remove" and old_text:
preview = old_text[:60] + ("" if len(old_text) > 60 else "")
actions.append(f"{label} {preview}")
else:
actions.append(f"{label} updated")
elif (
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):
actions.append(f"{label} updated")
return actions
@@ -522,6 +623,7 @@ def _run_review_in_thread(
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:
@@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
return path
}
if (parsed.searchParams.has('profile')) {
return path
}
parsed.searchParams.set('profile', scopedProfile)
return `${parsed.pathname}${parsed.search}${parsed.hash}`
}
function tokenPreview(value) {
const raw = String(value || '')
@@ -247,6 +280,7 @@ module.exports = {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -24,6 +24,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=iris'
)
})
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/options?force=1&profile=iris'
)
})
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=default'
)
})
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: false,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: true
}),
'/api/model/info'
)
})
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', '', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
''
)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
+79 -58
View File
@@ -63,6 +63,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
@@ -5083,65 +5084,75 @@ function focusWindow(win) {
win.focus()
}
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch,
newSession
})
)
return win
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId, { watch = false } = {}) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: SESSION_WINDOW_MIN_WIDTH,
height: SESSION_WINDOW_MIN_HEIGHT,
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
// Don't show until the renderer's first themed paint is ready. macOS
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
// material (which follows the OS appearance, not the app theme), so a
// dark-themed app on a light-mode Mac flashes white until the renderer
// covers it. ready-to-show fires after the boot-time paint in
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
}
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show()
})
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
watch
})
)
return win
})
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
// like ⌘N in a browser, every press opens a new window — and a draft window that
// later converts to a real session must not get refocused as if it were blank.
function createNewSessionWindow() {
return spawnSecondaryWindow({ newSession: true })
}
function createWindow() {
@@ -5328,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
return { ok: true }
})
ipcMain.handle('hermes:window:openNewSession', async () => {
createNewSessionWindow()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -5597,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const profile = request?.profile
const connection = await ensureBackend(profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),
profileRemoteOverride: profileHasRemoteOverride(profile)
})
const url = `${connection.baseUrl}${requestPath}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
+1
View File
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
+7 -6
View File
@@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar. `watch=1` marks a
// spectator window (e.g. a running subagent's session): the renderer resumes
// it lazily so the gateway never builds an agent just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
const query = `?win=secondary${watch ? '&watch=1' : ''}`
const route = `#/${encodeURIComponent(sessionId)}`
// onboarding overlays and the global session sidebar. `new=1` marks the compact
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
@@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
})
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
+4 -8
View File
@@ -23,6 +23,7 @@ import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { mediaExternalUrl } from '@/lib/media'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
@@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
}
function artifactHref(value: string): string {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
if (value.startsWith('file://') || value.startsWith('/')) {
return mediaExternalUrl(value)
}
return value
+6 -2
View File
@@ -42,6 +42,7 @@ import {
$sessions,
sessionPinId
} from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
@@ -122,7 +123,7 @@ function ChatHeader({
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@@ -302,7 +303,10 @@ export function ChatView({
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -77,6 +77,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { onSessionsChanged } from '../store/session-sync'
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
@@ -464,6 +465,17 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
// only real windows bother.
useEffect(() => {
if (isSecondaryWindow()) {
return
}
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
@@ -37,6 +37,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { openNewSessionInNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.newWindow': () => void openNewSessionInNewWindow(),
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,
@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { $connection, setCurrentCwd } from '@/store/session'
import { resetProjectTreeState } from './files/use-project-tree'
import { RightSidebarPane } from './index'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const selectPaths = vi.fn()
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
function installBridge() {
;(
window as unknown as {
hermesDesktop: {
readDir: typeof readDir
selectPaths: typeof selectPaths
}
}
).hermesDesktop = { readDir, selectPaths }
}
describe('RightSidebarPane', () => {
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
setCurrentCwd('/repo')
readDir.mockReset()
selectPaths.mockReset()
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
selectPaths.mockResolvedValue(['/repo-next'])
installBridge()
})
afterEach(() => {
cleanup()
$connection.set(null)
setCurrentCwd('')
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('refreshes the current tree without opening the folder picker', async () => {
const onChangeCwd = vi.fn()
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
readDir.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
expect(selectPaths).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
await waitFor(() =>
expect(selectPaths).toHaveBeenCalledWith({
defaultPath: '/repo',
directories: true,
multiple: false,
title: 'Change working directory'
})
)
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
})
})
+5 -5
View File
@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
// stays visible while any folder is expanded.
// Sidebar palette + hover-reveal: header actions stay reachable while moving
// from the project label to the action buttons.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
function FilesystemTab({
canCollapse,
@@ -158,7 +158,7 @@ function FilesystemTab({
return (
<div className="flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<div className="peer/project-label flex min-w-0 flex-1">
<div className="flex min-w-0 flex-1">
<button
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
@@ -216,7 +216,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
}
interface FileTreeBodyProps {
@@ -47,6 +47,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
@@ -641,6 +642,9 @@ export function useMessageStream({
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@@ -1141,6 +1142,81 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
// meaningful when that process runs on this machine, so it's gated to
// local connections. A remote gateway would act on the wrong host.
browser: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if ($connection.get()?.mode === 'remote') {
renderSlashOutput(
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
)
return
}
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
const cmdAction = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
renderSlashOutput(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return
}
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
}
try {
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
action: cmdAction,
session_id: sessionId,
...(url && { url })
})
// Without a streamed session subscription, the gateway bundles its
// progress lines into `messages` — flush them inline.
result?.messages?.forEach(message => renderSlashOutput(message))
if (cmdAction === 'status') {
renderSlashOutput(
result?.connected
? `browser connected: ${result.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
return
}
if (cmdAction === 'disconnect') {
renderSlashOutput('browser disconnected')
return
}
if (result?.connected) {
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
renderSlashOutput('next browser tool call will use this CDP endpoint')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
@@ -42,6 +42,7 @@ import {
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { reportBackendContract } from '@/store/updates'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
@@ -472,6 +473,9 @@ export function useSessionActions({
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
// Other windows (e.g. the main window when this is the pop-out) can't
// see this session until they re-pull the shared list.
broadcastSessionsChanged()
}
setFreshDraftReady(false)
+8 -3
View File
@@ -16,7 +16,7 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@@ -80,6 +80,7 @@ export function AppShell({
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const hideTitlebarControls = isNewSessionWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via
@@ -162,7 +163,9 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
{!hideTitlebarControls && (
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
)}
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">
@@ -183,7 +186,9 @@ export function AppShell({
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
{/* The compact pop-out drops the statusbar it's a scratch window, not
the full shell. */}
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
</main>
{overlays}
+6
View File
@@ -46,6 +46,12 @@ export interface SlashExecResponse {
warning?: string
}
export interface BrowserManageResponse {
connected?: boolean
url?: string
messages?: string[]
}
export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues.
@@ -1,5 +1,6 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type CSSProperties,
type ComponentProps,
type FC,
memo,
@@ -21,6 +22,7 @@ import {
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary'
@@ -132,6 +134,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
const newSessionWindow = isNewSessionWindow()
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
const threadContentTopPad = newSessionWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow()
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
@@ -235,7 +244,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
} as CSSProperties
}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
@@ -252,9 +266,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
+2
View File
@@ -24,6 +24,8 @@ declare global {
// a spectator window (lazy resume — no agent build) for live-streaming
// a running subagent's session.
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
// Open (or focus) a compact secondary window on the new-session draft.
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
+1
View File
@@ -189,6 +189,7 @@ export const en: Translations = {
'nav.cron': 'Open scheduled jobs',
'nav.agents': 'Open agents',
'session.new': 'New session',
'session.newWindow': 'New session in window',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',
+1
View File
@@ -185,6 +185,7 @@ export const zh: Translations = {
'nav.cron': '打开定时任务',
'nav.agents': '打开智能体',
'session.new': '新建会话',
'session.newWindow': '在新窗口中新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
appendReasoningPart,
chatMessageText,
preserveLocalAssistantErrors,
renderMediaTags,
@@ -175,6 +176,52 @@ describe('renderMediaTags', () => {
})
})
describe('interleaved reasoning/text coalescing', () => {
it('keeps narration contiguous when reasoning interrupts mid-sentence', () => {
// Models that interleave reasoning_content + content deltas emit
// text → reasoning → text within one tool-bounded segment. The two text
// fragments are really one sentence and must not be split by the
// "Thinking" block between them.
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ')
parts = appendReasoningPart(parts, 'checking the file...')
parts = appendAssistantTextPart(parts, 'verify the full file is correct:')
expect(parts.map(p => p.type)).toEqual(['text', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:')
expect((parts[1] as { text: string }).text).toBe('checking the file...')
})
it('merges reasoning bursts that straddle a narration fragment', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ')
parts = appendAssistantTextPart(parts, 'Working on it.')
parts = appendReasoningPart(parts, 'second thought')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'text'])
expect((parts[0] as { text: string }).text).toBe('first thought second thought')
expect((parts[1] as { text: string }).text).toBe('Working on it.')
})
it('starts a fresh text part after a tool call (segment boundary)', () => {
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendAssistantTextPart(parts, 'Now editing.')
expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text'])
expect((parts[0] as { text: string }).text).toBe('Let me check.')
expect((parts[2] as { text: string }).text).toBe('Now editing.')
})
it('does not merge reasoning across a tool call', () => {
let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool')
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
parts = appendReasoningPart(parts, 'after tool')
expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning'])
expect((parts[0] as { text: string }).text).toBe('before tool')
expect((parts[2] as { text: string }).text).toBe('after tool')
})
})
describe('preserveLocalAssistantErrors', () => {
it('preserves a local user+error pair when hydration omits the failed turn', () => {
const nextMessages: ChatMessage[] = [
+49 -29
View File
@@ -178,50 +178,70 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
}
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
if (last?.type === 'text') {
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
return next
}
next.push(textPart(delta))
return next
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
reasoning: reasoningPart,
text: textPart
}
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = appendTextPart(parts, delta)
const last = next.at(-1)
// Coalesce a streaming delta into the most recent same-type part within the
// current segment, where a segment is bounded by any non-streaming part (a
// tool call, image, …). The opposite streaming channel (text <-> reasoning) is
// transparent, so a reasoning burst between two content deltas can't shred one
// sentence into text / Thinking / text — the fragmentation models that
// interleave reasoning_content + content otherwise produce. Tool calls still
// open a fresh part, preserving narration order across steps.
function appendStreamPart(
parts: ChatMessagePart[],
type: 'reasoning' | 'text',
delta: string
): { index: number; parts: ChatMessagePart[] } {
const next = [...parts]
if (last?.type === 'text') {
const current = last.text
for (let i = next.length - 1; i >= 0; i--) {
const part = next[i]
const deltaMayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (part.type === type) {
next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
const nextText = needsMediaPass ? renderMediaTags(current) : current
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
return { index: i, parts: next }
}
if (part.type !== 'text' && part.type !== 'reasoning') {
break
}
}
return next
next.push(STREAM_PART[type](delta))
return { index: next.length - 1, parts: next }
}
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
return appendStreamPart(parts, 'text', delta).parts
}
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const next = [...parts]
const last = next.at(-1)
return appendStreamPart(parts, 'reasoning', delta).parts
}
if (last?.type === 'reasoning') {
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
const { index, parts: next } = appendStreamPart(parts, 'text', delta)
const part = next[index]
if (part?.type !== 'text') {
return next
}
next.push(reasoningPart(delta))
const mayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
if (mayContainMedia || part.text.includes('MEDIA:')) {
const rendered = renderMediaTags(part.text)
if (rendered !== part.text) {
next[index] = { ...part, text: rendered }
}
}
return next
}
@@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
expect(isDesktopSlashCommand('/browser')).toBe(true)
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
// Bare /browser expands to its sub-action options in the popover.
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
@@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
*/
export type DesktopActionId =
| 'branch'
| 'browser'
| 'handoff'
| 'help'
| 'new'
@@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
{
name: '/browser',
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
surface: action('browser'),
args: true
},
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
@@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
+1
View File
@@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
+33 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
describe('isRemoteGateway', () => {
afterEach(() => {
@@ -35,6 +35,38 @@ describe('filePathFromMediaPath', () => {
})
})
describe('mediaExternalUrl', () => {
afterEach(() => {
$connection.set(null)
})
it('passes through http(s) URLs untouched', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never)
expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
})
it('keeps file:// form in local mode', () => {
$connection.set({ mode: 'local' } as never)
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png')
})
it('rewrites gateway-local paths to an authenticated download URL', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
expect(mediaExternalUrl('file:///tmp/a b.png')).toBe(
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
)
expect(mediaExternalUrl('/tmp/a b.png')).toBe(
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
)
})
it('falls back to file:// when remote connection lacks a token', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never)
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
})
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
+18 -1
View File
@@ -56,8 +56,25 @@ export function mediaMarkdownHref(path: string): string {
return `#media:${encodeURIComponent(path)}`
}
// Resolve a media path to a URL the shell can open. Remote mode rewrites
// gateway-local paths to an authenticated /api/files/download URL (the file
// lives on the gateway, not this disk); local mode keeps the file:// form.
export function mediaExternalUrl(path: string): string {
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
if (/^https?:/i.test(path)) {
return path
}
if (isRemoteGateway()) {
const conn = $connection.get()
if (conn?.baseUrl && conn.token) {
const file = encodeURIComponent(filePathFromMediaPath(path))
return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}`
}
}
return /^file:/i.test(path) ? path : `file://${path}`
}
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
+25
View File
@@ -0,0 +1,25 @@
// Cross-window session-list sync. Each desktop window is its own renderer
// process with its own gateway socket and session store, so a mutation in one
// (e.g. a new chat started in the compact pop-out) never reaches another
// window. This bus pings every window to re-pull the shared session list; the
// data already lives in the backend, the other window just doesn't know to look.
const CHANNEL = 'hermes:sessions'
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
// A window that mutated the session list (created / titled a chat) tells the
// others to refresh. A BroadcastChannel never delivers to its own poster, so the
// caller refreshes locally as it already does.
export function broadcastSessionsChanged(): void {
channel?.postMessage(1)
}
export function onSessionsChanged(handler: () => void): () => void {
if (!channel) {
return () => {}
}
channel.addEventListener('message', handler)
return () => channel.removeEventListener('message', handler)
}
+43 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
@@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({
notifyError: (...args: unknown[]) => notifyError(...args)
}))
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
function installBridge(
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
) {
desktopWindow.hermesDesktop = {
...(openSessionWindow ? { openSessionWindow } : {})
...(openSessionWindow ? { openSessionWindow } : {}),
...(openNewSessionWindow ? { openNewSessionWindow } : {})
} as unknown as Window['hermesDesktop']
}
@@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => {
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
describe('openNewSessionInNewWindow', () => {
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
delete desktopWindow.hermesDesktop
await openNewSessionInNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('no-ops when openNewSessionWindow is missing', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }))
await openNewSessionInNewWindow()
expect(notifyError).not.toHaveBeenCalled()
})
it('invokes the bridge', async () => {
const openNew = vi.fn().mockResolvedValue({ ok: true })
installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew)
await openNewSessionInNewWindow()
expect(openNew).toHaveBeenCalledTimes(1)
expect(notifyError).not.toHaveBeenCalled()
})
it('notifies on an ok:false result', async () => {
installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
await openNewSessionInNewWindow()
expect(notifyError).toHaveBeenCalledTimes(1)
})
})
+45 -7
View File
@@ -6,6 +6,7 @@ import { notifyError } from './notifications'
// never from the router. A "secondary" window renders a single chat without the
// global session sidebar or the install / onboarding overlays.
const SECONDARY_WINDOW_FLAG = 'secondary'
const NEW_SESSION_WINDOW_FLAG = '1'
let secondaryWindowCache: boolean | null = null
@@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean {
return result
}
let newSessionWindowCache: boolean | null = null
export function isNewSessionWindow(): boolean {
if (newSessionWindowCache !== null) {
return newSessionWindowCache
}
let result = false
try {
result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG
} catch {
result = false
}
newSessionWindowCache = result
return result
}
let watchWindowCache: boolean | null = null
// A "watch" window spectates a session that is being driven elsewhere (a
@@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean {
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
}
type WindowOpenResult = { ok: boolean; error?: string } | undefined
// Run a window-open bridge call, surfacing any failure as a toast. Shared by the
// session pop-out and the new-session pop-out.
async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
try {
const result = await call()
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), failMessage)
}
} catch (err) {
notifyError(err, failMessage)
}
}
// Open (or focus) a standalone OS window for a single chat session. No-ops
// gracefully outside Electron so callers can wire it unconditionally.
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
@@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
return
}
try {
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
}
if (!result?.ok) {
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
}
} catch (err) {
notifyError(err, 'Could not open chat in a new window')
// Open a fresh compact window on the new-session draft.
export async function openNewSessionInNewWindow(): Promise<void> {
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
return
}
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
}
+1 -1
View File
@@ -724,7 +724,7 @@ platform_toolsets:
# # allowed_chats: ["-1001234567890"]
# extra:
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
#
# Discord-specific settings (config.yaml top-level, not under platforms:):
#
+162 -25
View File
@@ -1273,6 +1273,11 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
return None
# Lock the worktree so concurrent/later hermes processes' pruning
# leaves this session's work alone (locks survive crashes too).
# Lock failure is non-fatal — _lock_worktree logs at debug level.
_lock_worktree(repo_root, str(wt_path))
# Copy files listed in .worktreeinclude (gitignored files the agent needs)
include_file = Path(repo_root) / ".worktreeinclude"
if include_file.exists():
@@ -1383,13 +1388,109 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo
return True
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
"""Remove a worktree and its branch on exit.
def _lock_worktree(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
"""Lock a worktree using git's native lock mechanism.
Preserves the worktree only if it has unpushed commits (real work
that hasn't been pushed to any remote). Uncommitted changes alone
(untracked files, test artifacts) are not enough to keep it agent
work lives in commits/PRs, not the working tree.
The lock marks the worktree as in-use by a live (or crashed) hermes
session so that other hermes processes' pruning leaves it alone.
Never raises; returns whether the lock was taken.
"""
import subprocess
try:
result = subprocess.run(
["git", "worktree", "lock",
"--reason", f"hermes session pid={os.getpid()}", str(wt_path)],
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
logger.debug(
"Failed to lock worktree %s: %s", wt_path, result.stderr.strip()
)
return False
return True
except Exception as e:
logger.debug("Failed to lock worktree %s: %s", wt_path, e)
return False
def _unlock_worktree(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
"""Release a git worktree lock. Never raises."""
import subprocess
try:
result = subprocess.run(
["git", "worktree", "unlock", str(wt_path)],
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
logger.debug(
"Failed to unlock worktree %s: %s", wt_path, result.stderr.strip()
)
return False
return True
except Exception as e:
logger.debug("Failed to unlock worktree %s: %s", wt_path, e)
return False
def _worktree_is_locked(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
"""Return whether a worktree is locked (per ``git worktree list --porcelain``).
Fails SAFE: on any error (bad repo_root, git failure, timeout) returns
True so callers treat the worktree as in-use and do not delete it.
"""
import subprocess
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
return True
target = Path(wt_path).resolve()
current_path: Optional[Path] = None
for line in result.stdout.splitlines():
if line.startswith("worktree "):
current_path = Path(line[len("worktree "):].strip()).resolve()
elif line == "locked" or line.startswith("locked "):
if current_path == target:
return True
return False
except Exception:
return True
def _worktree_is_dirty(wt_path: str, timeout: int = 10) -> bool:
"""Return whether a worktree has uncommitted changes (staged, unstaged,
or untracked).
Fails SAFE: on any error returns True so callers do not delete a
worktree whose state they cannot determine.
"""
import subprocess
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=timeout, cwd=wt_path,
)
if result.returncode != 0:
return True
return bool(result.stdout.strip())
except Exception:
return True
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
"""Remove a worktree and its branch on graceful exit.
Preserves the worktree (along with its branch and lock) if it has
unpushed commits OR uncommitted changes either may be work the user
has not retrieved yet. Only clean, fully-pushed worktrees are
removed, and the branch is only deleted after ``git worktree remove``
actually succeeded.
"""
global _active_worktree
info = info or _active_worktree
@@ -1406,24 +1507,41 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
return
has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)
is_dirty = _worktree_is_dirty(wt_path)
if has_unpushed:
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
print(f" To clean up manually: git worktree remove --force {wt_path}")
if has_unpushed or is_dirty:
reason = "unpushed commits" if has_unpushed else "uncommitted changes"
print(f"\n\033[33m⚠ Worktree has {reason}, keeping: {wt_path}\033[0m")
print(f" To clean up manually: git worktree unlock {wt_path}")
print(f" then: git worktree remove --force {wt_path}")
_active_worktree = None
return
# Remove worktree (even if working tree is dirty — uncommitted
# changes without unpushed commits are just artifacts)
# Clean and fully pushed — release our lock, then remove.
_unlock_worktree(repo_root, wt_path)
removed = False
try:
subprocess.run(
result = subprocess.run(
["git", "worktree", "remove", wt_path, "--force"],
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
removed = result.returncode == 0
if not removed:
logger.debug(
"Failed to remove worktree %s: %s", wt_path, result.stderr.strip()
)
except Exception as e:
logger.debug("Failed to remove worktree: %s", e)
# Delete the branch
if not removed:
# Removal failed — keep the branch so the commits stay reachable.
print(f"\033[33m⚠ Could not remove worktree, keeping it (and branch "
f"{branch}): {wt_path}\033[0m")
_active_worktree = None
return
# Delete the branch only now that the worktree is actually gone.
try:
subprocess.run(
["git", "branch", "-D", branch],
@@ -1517,10 +1635,14 @@ def _run_checkpoint_auto_maintenance() -> None:
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
"""Remove stale worktrees and orphaned branches on startup.
Age-based tiers:
Pruning may only ever delete clean, unlocked, fully-pushed worktrees:
- Under max_age_hours (24h): skip session may still be active.
- 24h72h: remove if no unpushed commits.
- Over 72h: force remove regardless (nothing should sit this long).
- Locked (a live or crashed hermes session): skip at ANY age.
- Dirty working tree (uncommitted changes): skip at ANY age.
- Unpushed commits: skip at ANY age.
The branch is only deleted after ``git worktree remove`` actually
succeeded, so commits never lose their easy reachability.
Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that
have no corresponding worktree.
@@ -1535,7 +1657,6 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
now = time.time()
soft_cutoff = now - (max_age_hours * 3600) # 24h default
hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h default
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
@@ -1549,14 +1670,22 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
except Exception:
continue
force = mtime <= hard_cutoff # Over 72h — force remove
# A lock means a session (live, or crashed mid-work) owns this
# worktree — never touch it, regardless of age.
if _worktree_is_locked(repo_root, str(entry)):
logger.debug("Skipping locked worktree: %s", entry.name)
continue
if not force:
# 24h72h tier: only remove if no unpushed commits
if _worktree_has_unpushed_commits(str(entry), timeout=5):
continue # Has unpushed commits or can't check — skip
# Uncommitted changes may be work the user hasn't retrieved.
if _worktree_is_dirty(str(entry)):
logger.debug("Skipping dirty worktree: %s", entry.name)
continue
# Safe to remove
# Unpushed commits are definitely work — keep at any age.
if _worktree_has_unpushed_commits(str(entry), timeout=5):
continue
# Safe to remove: clean, unlocked, fully pushed.
try:
branch_result = subprocess.run(
["git", "branch", "--show-current"],
@@ -1564,16 +1693,24 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
)
branch = branch_result.stdout.strip()
subprocess.run(
remove_result = subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
if remove_result.returncode != 0:
# Removal failed — keep the branch so the commits stay
# reachable.
logger.debug(
"Failed to remove worktree %s: %s",
entry.name, remove_result.stderr.strip(),
)
continue
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
logger.debug("Pruned stale worktree: %s", entry.name)
except Exception as e:
logger.debug("Failed to prune worktree %s: %s", entry.name, e)
+4
View File
@@ -32,6 +32,7 @@ from typing import Any
_GLOBAL_DEFAULTS: dict[str, Any] = {
"tool_progress": "all",
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
@@ -238,6 +239,9 @@ def _normalise(setting: str, value: Any) -> Any:
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
return bool(value)
if setting == "tool_progress_grouping":
val = str(value).lower()
return val if val in ("accumulate", "separate") else "accumulate"
if setting == "tool_preview_length":
try:
return int(value)
+25 -24
View File
@@ -77,6 +77,13 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
return metadata
def _mark_notify_metadata(metadata: dict | None) -> dict:
"""Clone metadata and mark a user-visible reply as notify-worthy."""
notify_metadata = dict(metadata) if metadata else {}
notify_metadata["notify"] = True
return notify_metadata
def _reply_anchor_for_event(event) -> str | None:
"""Return reply_to id for platforms that need reply semantics.
@@ -3889,7 +3896,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
content=_text,
reply_to=_reply_anchor_for_event(event),
metadata=thread_meta,
metadata=_mark_notify_metadata(thread_meta),
)
if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete(
@@ -3995,7 +4002,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
content=_text,
reply_to=_reply_anchor_for_event(event),
metadata=_thread_meta,
metadata=_mark_notify_metadata(_thread_meta),
)
if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete(
@@ -4045,7 +4052,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
content=_text,
reply_to=_reply_anchor_for_event(event),
metadata=_thread_meta,
metadata=_mark_notify_metadata(_thread_meta),
)
if _eph_ttl > 0 and _r.success and _r.message_id:
self._schedule_ephemeral_delete(
@@ -4268,6 +4275,12 @@ class BasePlatformAdapter(ABC):
)
text_content = _recovered
# Final user-visible content (text, TTS, media, files) gets
# the existing notify=True marker. Clone once so typing/status
# metadata stays unmarked and progress bubbles remain
# thread-strict.
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
@@ -4307,7 +4320,7 @@ class BasePlatformAdapter(ABC):
chat_id=event.source.chat_id,
audio_path=_tts_path,
caption=telegram_tts_caption,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
_tts_caption_delivered = bool(
telegram_tts_caption and getattr(tts_result, "success", False)
@@ -4322,23 +4335,11 @@ class BasePlatformAdapter(ABC):
if text_content and not _tts_caption_delivered:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
_reply_anchor = _reply_anchor_for_event(event)
# Mark final response messages for notification delivery.
# Platform adapters that support per-message notification
# control (e.g. Telegram's disable_notification) use this
# flag to override silent-mode and ensure the final
# response triggers a push notification.
# Clone to avoid mutating the metadata shared with the
# typing-indicator task (which must remain unmarked).
if _thread_metadata is not None:
_thread_metadata = dict(_thread_metadata)
_thread_metadata["notify"] = True
else:
_thread_metadata = {"notify": True}
result = await self._send_with_retry(
chat_id=event.source.chat_id,
content=text_content,
reply_to=_reply_anchor,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
_record_delivery(result)
@@ -4367,7 +4368,7 @@ class BasePlatformAdapter(ABC):
await self.send_multiple_images(
chat_id=event.source.chat_id,
images=images,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
human_delay=human_delay,
)
except Exception as batch_err:
@@ -4409,7 +4410,7 @@ class BasePlatformAdapter(ABC):
await self.send_multiple_images(
chat_id=event.source.chat_id,
images=_batch,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
human_delay=human_delay,
)
except Exception as batch_err:
@@ -4424,19 +4425,19 @@ class BasePlatformAdapter(ABC):
media_result = await self.send_voice(
chat_id=event.source.chat_id,
audio_path=media_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
elif ext in _VIDEO_EXTS:
media_result = await self.send_video(
chat_id=event.source.chat_id,
video_path=media_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
else:
media_result = await self.send_document(
chat_id=event.source.chat_id,
file_path=media_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
if not media_result.success:
@@ -4454,13 +4455,13 @@ class BasePlatformAdapter(ABC):
await self.send_video(
chat_id=event.source.chat_id,
video_path=file_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
else:
await self.send_document(
chat_id=event.source.chat_id,
file_path=file_path,
metadata=_thread_metadata,
metadata=_final_thread_metadata,
)
except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
+6 -1
View File
@@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter):
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an image URL as part of an email body."""
"""Send an image URL as part of an email body.
``metadata`` is accepted to honor the base-class contract; the
email body send doesn't use it.
"""
text = caption or ""
text += f"\n\nImage: {image_url}"
return await self.send(chat_id, text.strip(), reply_to)
+140 -21
View File
@@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter):
self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
# replies via sendRichMessage with the raw agent markdown so
# tables/task lists/etc. render natively. Disabled by default because
# several Telegram clients accept but render rich messages poorly.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
# Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
# path degrades (tables → bullet lists, task lists, <details>, block
# math) via sendRichMessage / editMessageText's rich_message param using
# the raw agent markdown. Enabled by default; users can opt out for
# clients that accept but render rich messages poorly via
# platforms.telegram.extra.rich_messages: false.
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
# Latched off after a capability failure on sendRichMessage /
# sendRichMessageDraft (e.g. older python-telegram-bot without the
# endpoint) so later sends skip the doomed rich attempt entirely.
@@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter):
return True
return False
def _needs_rich_rendering(self, content: str) -> bool:
"""Return True for markdown constructs that the legacy path degrades.
Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
clients render a consistent font weight/spacing. The rich endpoint is
reserved for constructs where raw markdown materially improves output:
pipe tables (MarkdownV2 has no table syntax and rewrites them into
bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
block math. Adapted from #45995 (@YonganZhang).
"""
if not content:
return False
if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
return True
if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
return True
if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
return True
if "$$" in content:
return True
return False
def _rich_eligible(self, content: str) -> bool:
"""Capability/content eligibility for rich, ignoring ``expect_edits``.
Shared core of :meth:`_should_attempt_rich` minus the per-call
``expect_edits`` metadata gate. The rich EDIT-finalize path
(:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
``expect_edits=True`` to stay on the editable path mid-stream, but the
FINAL edit should still upgrade to rich when the content warrants it.
"""
return bool(
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and content
and content.strip()
and self._needs_rich_rendering(content)
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
)
def _should_attempt_rich(
self, content: str, metadata: Optional[Dict[str, Any]] = None
) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
and not getattr(self, "_rich_send_disabled", False)
and not (metadata or {}).get("expect_edits")
and content
and content.strip()
and not self._has_telegram_desktop_details_math_crash_shape(content)
and self._content_fits_rich_limits(content)
and self._bot_supports_rich()
not (metadata or {}).get("expect_edits")
and self._rich_eligible(content)
)
def prefers_fresh_final_streaming(
@@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter):
) -> bool:
"""Whether to replace a streamed preview with a fresh rich final.
Keep this disabled for Telegram. The fresh-final path briefly shows two
copies of the final answer, then deletes the streaming preview after the
rich send succeeds. That is especially visible on clients that support
rich messages well, and it looks like duplicate delivery at the end of
every streamed turn. Until Telegram rich edits are wired directly, final
streamed replies should edit the existing preview in place.
Disabled for Telegram. The fresh-final path briefly shows two copies of
the final answer, then deletes the streaming preview after the rich send
succeeds it looks like duplicate delivery at the end of every streamed
turn (the reason #46206 reverted it). Rich finalize is instead handled
by editing the existing preview in place via Bot API 10.1's
``editMessageText`` ``rich_message`` parameter (see
:meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
"""
return False
@@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter):
streams split exactly as before.
"""
if (
getattr(self, "_rich_messages_enabled", False)
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and self._bot_supports_rich()
):
@@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter):
message_id=str(message_id) if message_id is not None else None,
)
async def _try_edit_rich(
self,
chat_id: str,
message_id: str,
content: str,
) -> Optional[SendResult]:
"""Edit an existing message in place as a rich message (Bot API 10.1).
Uses ``editMessageText`` with the ``rich_message`` parameter so a
streamed preview can finalize as rich (tables/task lists/details/math)
WITHOUT a fresh send + delete no duplicate preview. Mirrors
:meth:`_try_send_rich`'s error contract:
- success ``SendResult(success=True, message_id=...)``
- permanent / capability error ``None`` (caller falls back to the
legacy MarkdownV2 edit; capability errors latch rich off)
- transient / unknown ``SendResult(success=False)`` with retry
semantics (the message may already be edited; do NOT legacy-resend)
"""
payload: Dict[str, Any] = {
"chat_id": int(chat_id),
"message_id": int(message_id),
"rich_message": self._rich_message_payload(content),
}
if getattr(self, "_disable_link_previews", False):
payload["link_preview_options"] = {"is_disabled": True}
try:
# Raw Bot API result; do not request return_type=Message (PTB does
# not fully model the 10.1 response shape yet — a post-edit parse
# error must not be mistaken for a failed edit).
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
except Exception as exc:
if self._is_rich_fallback_error(exc):
if self._is_rich_capability_error(exc):
self._rich_send_disabled = True
# "Message is not modified" — content identical to the current
# rich message; treat as a successful no-op so the caller does
# not fall through to a redundant legacy edit.
if "not modified" in str(exc).lower():
return SendResult(success=True, message_id=message_id)
logger.debug(
"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
self.name, exc,
)
return None
if "not modified" in str(exc).lower():
return SendResult(success=True, message_id=message_id)
err_str = str(exc).lower()
try:
from telegram.error import TimedOut as _TimedOut
except (ImportError, AttributeError):
_TimedOut = None
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
is_connect_timeout = self._looks_like_connect_timeout(exc)
logger.warning(
"[%s] rich editMessageText transient failure (no legacy resend): %s",
self.name, exc,
)
return SendResult(
success=False,
error=str(exc),
retryable=(is_connect_timeout or not is_timeout),
)
return SendResult(success=True, message_id=message_id)
def _should_attempt_rich_draft(self, content: str) -> bool:
return bool(
getattr(self, "_rich_messages_enabled", False)
getattr(self, "_rich_messages_enabled", True)
and not getattr(self, "_rich_send_disabled", False)
and not getattr(self, "_rich_draft_disabled", False)
and content
@@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._bot:
return SendResult(success=False, error="Not connected")
# Rich finalize (Bot API 10.1): when the completed content has
# constructs the legacy MarkdownV2 edit degrades (tables → bullet
# lists, task lists, <details>, block math) and rich is available,
# edit the preview IN PLACE via editMessageText's rich_message param.
# No fresh send + delete → no duplicate preview (the problem #46206
# reverted the fresh-final path for). Attempted before the 4,096
# overflow pre-flight because the rich text cap is 32,768 — a rich
# table that exceeds the MarkdownV2 limit must not be split into legacy
# chunks. Falls back to the legacy edit path (overflow split included)
# on capability/permanent rejection.
if finalize and self._rich_eligible(content):
rich_result = await self._try_edit_rich(chat_id, message_id, content)
if rich_result is not None:
return rich_result
# Pre-flight: if content already exceeds the limit, split-and-deliver
# without round-tripping a doomed edit.
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
+18 -2
View File
@@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download image URL to cache, send natively via bridge."""
"""Download image URL to cache, send natively via bridge.
``metadata`` is accepted to honor the base-class contract the
batch sender ``send_multiple_images`` passes it through to every
send path. The bridge media call doesn't use it, matching the
sibling overrides (send_video / send_voice / send_document).
"""
try:
local_path = await cache_image_from_url(image_url)
return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
except Exception:
return await super().send_image(chat_id, image_url, caption, reply_to)
return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
async def send_image_file(
self,
@@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
body = data.get("body", "")
if data.get("isGroup"):
body = self._clean_bot_mention_text(body, data)
# If this is a reply, include the quoted message text so the agent
# knows exactly what the user is responding to (fixes "approve" context issue)
quoted_text = str(data.get("quotedText") or "").strip()
if quoted_text and data.get("hasQuotedMessage"):
# Truncate long quoted text to keep prompts reasonable
if len(quoted_text) > 300:
quoted_text = quoted_text[:297] + "..."
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
MAX_TEXT_INJECT_BYTES = 100 * 1024
if msg_type == MessageType.DOCUMENT and cached_urls:
for doc_path in cached_urls:
+133 -19
View File
@@ -402,6 +402,68 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met
return await adapter.send(chat_id, content, metadata=metadata)
def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]:
"""Return thread/root ID that progress/status bubbles should target."""
platform_value = getattr(platform, "value", platform)
platform_key = str(platform_value or "").lower()
if source_thread_id:
return str(source_thread_id)
if platform_key in {"slack", "mattermost"} and event_message_id:
return str(event_message_id)
return None
def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool:
"""Return True when display.platforms.<platform> explicitly sets setting."""
display = user_config.get("display") if isinstance(user_config, dict) else None
if not isinstance(display, dict):
return False
platforms = display.get("platforms")
if not isinstance(platforms, dict):
return False
platform_cfg = platforms.get(platform_key)
return isinstance(platform_cfg, dict) and setting in platform_cfg
def _resolve_gateway_display_bool(
user_config: dict,
platform_key: str,
setting: str,
*,
default: bool = False,
platform: Any = None,
require_platform_override_for: set[Any] | None = None,
) -> bool:
"""Resolve a boolean display setting with optional platform-only opt-in.
Some display features expose assistant scratch text rather than deliberate
user-facing output. For high-noise threaded chat surfaces such as
Mattermost, a global opt-in is too broad: they must be enabled with an
explicit display.platforms.<platform>.<setting> override.
"""
current_platform = _gateway_platform_value(platform or platform_key)
platform_only = {
_gateway_platform_value(candidate)
for candidate in (require_platform_override_for or set())
}
if (
current_platform in platform_only
and not _has_platform_display_override(user_config, platform_key, setting)
):
return False
from gateway.display_config import resolve_display_setting
value = resolve_display_setting(user_config, platform_key, setting, default)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"true", "yes", "1", "on"}
if value is None:
return bool(default)
return bool(value)
def _telegramize_command_mentions(text: str, platform: Any) -> str:
"""Rewrite slash-command mentions to Telegram-valid command names.
@@ -8978,17 +9040,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
source, session_entry, reason="agent-result-compression",
)
# Prepend reasoning/thinking if display is enabled (per-platform)
# Prepend reasoning/thinking if display is enabled (per-platform).
# Mattermost requires explicit per-platform opt-in because this is
# scratch text, not ordinary final-answer content.
try:
from gateway.display_config import resolve_display_setting as _rds
_show_reasoning_effective = _rds(
_show_reasoning_effective = _resolve_gateway_display_bool(
_load_gateway_config(),
_platform_config_key(source.platform),
"show_reasoning",
getattr(self, "_show_reasoning", False),
default=bool(getattr(self, "_show_reasoning", False)),
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
except Exception:
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
_show_reasoning_effective = (
False
if source.platform == Platform.MATTERMOST
else getattr(self, "_show_reasoning", False)
)
if _show_reasoning_effective and response and not _intentional_silence:
last_reasoning = agent_result.get("last_reasoning")
if last_reasoning:
@@ -13613,6 +13682,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
from gateway.config import Platform
@@ -13622,18 +13693,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# in chat platforms while opting into concise mid-turn updates.
interim_assistant_messages_enabled = (
source.platform != Platform.WEBHOOK
and bool(
resolve_display_setting(
user_config,
platform_key,
"interim_assistant_messages",
True,
)
and _resolve_gateway_display_bool(
user_config,
platform_key,
"interim_assistant_messages",
default=True,
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
)
# thinking_progress is independent — if enabled, we need the progress
# queue even when tool_progress is off (thinking relay uses same infra).
# Mattermost requires a per-platform opt-in: global scratch-text display
# is too easy to leak into busy public threads.
_thinking_enabled = _resolve_gateway_display_bool(
user_config,
platform_key,
"thinking_progress",
default=False,
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
needs_progress_queue = tool_progress_enabled or _thinking_enabled
# Queue for progress messages (thread-safe)
progress_queue = queue.Queue() if tool_progress_enabled else None
progress_queue = queue.Queue() if needs_progress_queue else None
last_tool = [None] # Mutable container for tracking in closure
last_progress_msg = [None] # Track last message for dedup
repeat_count = [0] # How many times the same message repeated
@@ -13739,6 +13824,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("tool-progress onboarding hint failed: %s", _hint_err)
return
# "_thinking" is assistant scratch text between tool calls. It
# is never ordinary tool progress: only relay it when the platform
# explicitly opted into thinking_progress. Handle both legacy
# callback shapes: ("_thinking", text) and
# ("reasoning.available", "_thinking", text, ...).
if event_type == "_thinking" or tool_name == "_thinking":
if not _thinking_enabled:
return
thinking_text = preview if tool_name == "_thinking" else tool_name
msg = f"💬 {thinking_text}" if thinking_text else None
if msg:
progress_queue.put(msg)
return
# If tool_progress is off, only _thinking passes through (above).
# Regular tool calls are suppressed.
if not tool_progress_enabled:
return
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
if event_type not in {"tool.started",}:
@@ -13884,10 +13987,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# - Feishu only honors reply_in_thread when sending a reply, so topic
# progress uses the triggering event message as the reply target
# - Other platforms should use explicit source.thread_id only
if source.platform == Platform.SLACK:
_progress_thread_id = source.thread_id or event_message_id
else:
_progress_thread_id = source.thread_id
_progress_thread_id = _resolve_progress_thread_id(
source.platform, source.thread_id, event_message_id,
)
_progress_metadata = (
self._thread_metadata_for_source(source, event_message_id)
if _progress_thread_id == source.thread_id
@@ -13920,7 +14022,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
progress_msg_id = None # ID of the current progress message to edit
can_edit = True # False once an edit fails (platform doesn't support it)
can_edit = progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior)
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
@@ -14687,6 +14789,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
if _pdc is not None:
_pdc[session_key] = _release_bg_review_messages
# Memory update notifications in chat. Config: display.memory_notifications
# off — no chat notification (still logged to stdout)
# on — generic "💾 Memory updated" (default)
# verbose — content preview: "💾 Memory Hermes Repo..."
_mem_notif = user_config.get("display", {}).get("memory_notifications")
if isinstance(_mem_notif, bool):
_mem_notif = "on" if _mem_notif else "off"
agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on"
# ------------------------------------------------------------------
# Clarify callback: present a clarify prompt and block on a response.
@@ -14763,6 +14873,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent.clarify_callback = _clarify_callback_sync
# Show assistant thinking between tool calls — independent of
# tool_progress mode. Mattermost needs an explicit per-platform
# opt-in so global scratch-text display does not leak into threads.
agent.thinking_progress = _thinking_enabled
# Store agent reference for interrupt support
agent_holder[0] = agent
# Capture the full tool definitions for transcript logging
+43 -10
View File
@@ -197,6 +197,30 @@ class GatewayStreamConsumer:
# this response and route through edit-based for graceful degradation.
self._draft_failures = 0
def _metadata_for_send(
self,
*,
final: bool = False,
expect_edits: bool = False,
) -> dict | None:
"""Return per-send metadata for stream-created messages.
Mattermost treats notify-worthy sends as user-visible final content
when deciding whether a broken thread root may fall back flat. Preview
and progress sends keep their original metadata and remain thread-strict.
``expect_edits`` preserves the upstream Telegram streaming contract:
preview messages that may be edited later must stay on the editable
legacy send path, while fresh/fallback final sends can still use richer
final-message delivery.
"""
meta = dict(self.metadata) if self.metadata else {}
if expect_edits:
meta["expect_edits"] = True
if final:
meta["notify"] = True
return meta or None
@property
def already_sent(self) -> bool:
"""True if at least one message was sent or edited during the run."""
@@ -513,7 +537,11 @@ class GatewayStreamConsumer:
chunks_delivered = False
reply_to = self._message_id or self._initial_reply_to_id
for chunk in chunks:
new_id = await self._send_new_chunk(chunk, reply_to)
new_id = await self._send_new_chunk(
chunk,
reply_to,
final=got_done,
)
if new_id is not None and new_id != reply_to:
chunks_delivered = True
self._accumulated = ""
@@ -749,7 +777,13 @@ class GatewayStreamConsumer:
# Strip trailing whitespace/newlines but preserve leading content
return cleaned.rstrip()
async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]:
async def _send_new_chunk(
self,
text: str,
reply_to_id: Optional[str],
*,
final: bool = False,
) -> Optional[str]:
"""Send a new message chunk, optionally threaded to a previous message.
Returns the message_id so callers can thread subsequent chunks.
@@ -758,15 +792,11 @@ class GatewayStreamConsumer:
if not text.strip():
return reply_to_id
try:
meta = dict(self.metadata) if self.metadata else {}
# This chunk becomes the next edit target — adapters that support
# rich final sends (Telegram) must keep it on the editable path.
meta["expect_edits"] = True
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
reply_to=reply_to_id,
metadata=meta,
metadata=self._metadata_for_send(final=final, expect_edits=True),
)
if result.success and result.message_id:
self._message_id = str(result.message_id)
@@ -885,7 +915,7 @@ class GatewayStreamConsumer:
result = await self.adapter.send(
chat_id=self.chat_id,
content=chunk,
metadata=self.metadata,
metadata=self._metadata_for_send(final=True),
)
if result.success:
break
@@ -1242,7 +1272,7 @@ class GatewayStreamConsumer:
result = await self.adapter.send(
chat_id=self.chat_id,
content=text,
metadata=self.metadata,
metadata=self._metadata_for_send(final=True),
)
except Exception as e:
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
@@ -1532,7 +1562,10 @@ class GatewayStreamConsumer:
chat_id=self.chat_id,
content=text,
reply_to=self._initial_reply_to_id,
metadata={**(self.metadata or {}), "expect_edits": True},
metadata=self._metadata_for_send(
final=finalize,
expect_edits=True,
),
)
if result.success:
if result.message_id:
+127 -11
View File
@@ -3806,6 +3806,26 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
pool_rate_limit = _codex_pool_rate_limit_status()
if pool_rate_limit:
reset_at = pool_rate_limit.get("reset_at")
if isinstance(reset_at, (int, float)) and reset_at > time.time():
remaining = int(reset_at - time.time())
message = (
f"Codex provider quota exhausted (429); retry after {remaining}s. "
"Credentials are still valid."
)
else:
message = (
"Codex provider quota exhausted (429). Credentials are still valid; "
"retry after the usage limit resets."
)
raise AuthError(
message,
provider="openai-codex",
code=CODEX_RATE_LIMITED_CODE,
relogin_required=False,
)
if read_error is not None:
raise read_error
raise AuthError(
@@ -3852,6 +3872,79 @@ def resolve_codex_runtime_credentials(
}
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
"""Return metadata for a pool-only Codex credential in quota cooldown."""
def _parse_reset_at(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
except ValueError:
numeric = None
if numeric is not None:
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
try:
with _auth_store_lock():
auth_store = _load_auth_store()
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
return None
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return None
now = time.time()
for entry in entries:
if not isinstance(entry, dict):
continue
token = entry.get("access_token")
if not isinstance(token, str) or not token.strip():
continue
if entry.get("last_status") != "exhausted":
continue
code = entry.get("last_error_code")
reason = str(entry.get("last_error_reason") or "").lower()
message = str(entry.get("last_error_message") or "").lower()
is_rate_limited = (
code == 429
or "rate_limit" in reason
or "usage_limit" in reason
or "quota" in reason
or "rate limit" in message
or "usage limit" in message
or "quota" in message
)
if not is_rate_limited:
continue
reset_at = _parse_reset_at(entry.get("last_error_reset_at"))
if reset_at is not None and reset_at <= now:
continue
return {
"label": entry.get("label"),
"last_refresh": entry.get("last_refresh"),
"reset_at": reset_at,
"reason": entry.get("last_error_reason"),
"message": entry.get("last_error_message"),
}
except Exception:
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
return None
def _pool_codex_access_token() -> str:
"""Return the most-recent usable access_token from the openai-codex pool.
@@ -5763,18 +5856,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
# path + mtime so that profile switches do not share a process memo and
# `hermes auth login/logout/add/remove` invalidate naturally on the next call.
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None
def _auth_file_mtime() -> Optional[float]:
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
auth_file = _auth_file_path()
try:
return _auth_file_path().stat().st_mtime
except FileNotFoundError:
return None
auth_file_key = str(auth_file.resolve(strict=False))
except Exception:
return None
auth_file_key = str(auth_file)
try:
return auth_file_key, auth_file.stat().st_mtime
except FileNotFoundError:
return auth_file_key, None
except Exception:
return auth_file_key, None
def invalidate_nous_auth_status_cache() -> None:
@@ -5806,18 +5905,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
"""
global _nous_auth_status_cache
now = time.monotonic()
mtime = _auth_file_mtime()
auth_file_key, mtime = _auth_file_cache_key()
cached = _nous_auth_status_cache
if cached is not None:
cached_at, cached_mtime, cached_status = cached
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
if (
cached_mtime == mtime
cached_auth_file_key == auth_file_key
and cached_mtime == mtime
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
):
return dict(cached_status)
status = _compute_nous_auth_status()
_nous_auth_status_cache = (now, mtime, dict(status))
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
return status
@@ -5900,6 +6000,22 @@ def get_codex_auth_status() -> Dict[str, Any]:
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
}
rate_limit = _codex_pool_rate_limit_status()
if rate_limit:
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": rate_limit.get("last_refresh"),
"auth_mode": "chatgpt",
"source": f"pool:{rate_limit.get('label') or 'unknown'}",
"rate_limited": True,
"error_code": CODEX_RATE_LIMITED_CODE,
"error": (
rate_limit.get("message")
or "Codex provider quota exhausted; retry after the usage limit resets."
),
"reset_at": rate_limit.get("reset_at"),
}
except Exception:
pass
+2 -1
View File
@@ -1053,7 +1053,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
def _sanitize_slack_name(raw: str) -> str:
+13 -1
View File
@@ -1428,6 +1428,12 @@ DEFAULT_CONFIG = {
"tui_agents_nudge": True,
"bell_on_complete": False,
"show_reasoning": False,
# Background self-improvement review notifications surfaced in chat.
# "off" — no chat notification (the review still runs and writes)
# "on" — generic "💾 Memory updated" line (default)
# "verbose" — include a compact content preview of what changed
# Per-platform overrides via display.platforms.<platform>.memory_notifications.
"memory_notifications": "on",
"streaming": False,
"timestamps": False, # Show [HH:MM] on user and assistant labels
"final_response_markdown": "strip", # render | strip | raw
@@ -1479,6 +1485,12 @@ DEFAULT_CONFIG = {
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
# How gateway tool-progress is grouped on platforms that support message
# editing: "accumulate" (default) edits one bubble in place; "separate"
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
# applies where tool_progress is already enabled. Per-platform override
# via display.platforms.<platform>.tool_progress_grouping.
"tool_progress_grouping": "accumulate",
# Auto-delete system-notice replies (e.g. "✨ New session started!",
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
# that support message deletion (currently Telegram; other platforms
@@ -1991,7 +2003,7 @@ DEFAULT_CONFIG = {
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
"extra": {
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
"rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
},
},
+267 -107
View File
@@ -247,6 +247,19 @@ def _has_valid_session_token(request: Request) -> bool:
return hmac.compare_digest(auth.encode(), expected.encode())
# Routes that may also authenticate via a ``?token=`` query param, for download
# links opened by the OS shell or a new browser tab where the session header
# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS.
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
def _has_valid_query_token(request: Request, path: str) -> bool:
if path not in _QUERY_TOKEN_API_PATHS:
return False
token = request.query_params.get("token", "")
return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
def _require_token(request: Request) -> None:
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
@@ -403,7 +416,7 @@ async def auth_middleware(request: Request, call_next):
return await call_next(request)
path = request.url.path
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
if not _has_valid_session_token(request):
if not _has_valid_session_token(request) and not _has_valid_query_token(request, path):
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
@@ -1224,6 +1237,22 @@ def _default_hermes_root_is_opt_data() -> bool:
return root == _HOSTED_MANAGED_FILES_ROOT
def _dashboard_local_update_managed_externally() -> bool:
"""Return true when the dashboard should not offer ``hermes update``.
Containerized dashboards are updated by the outer launcher/image, not by an
in-browser local update action. Keep this dashboard capability separate
from install-method detection: manual git/pip installs inside containers can
still behave like their actual install method in the CLI.
"""
try:
from hermes_constants import is_container
return is_container()
except Exception:
return False
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
if raw_forced_root:
@@ -1393,6 +1422,40 @@ async def read_managed_file(request: Request, path: str):
}
@app.get("/api/files/download")
async def download_managed_file(request: Request, path: str):
"""Stream a managed file as an attachment download.
Remote clients (desktop app, browser dashboard) open agent-written files
that live on *this* gateway's disk, not theirs. Auth-gated like every other
managed-files route ``auth_middleware`` additionally accepts the session
token as a ``?token=`` query param here so a shell/browser-opened download
(which can't set the session header) still authenticates. See ``/api/pty``
for the same query-token precedent.
"""
policy, target, _display_path = _resolve_managed_path(path, request)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
try:
size = target.stat().st_size
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
if size > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
return FileResponse(
path=str(target),
media_type=mime_type,
filename=target.name,
content_disposition_type="attachment",
)
@app.post("/api/files/upload")
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
@@ -1654,6 +1717,7 @@ async def get_status():
"release_date": __release_date__,
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
@@ -2165,6 +2229,22 @@ async def restart_gateway():
@app.post("/api/hermes/update")
async def update_hermes():
"""Kick off ``hermes update`` in the background."""
if _dashboard_local_update_managed_externally():
message = (
"Hermes updates are managed outside this dashboard in "
"containerized environments. The built-in local updater is "
"disabled here."
)
_record_completed_action("hermes-update", message, exit_code=1)
return {
"ok": False,
"pid": None,
"name": "hermes-update",
"error": "dashboard_update_managed_externally",
"message": message,
"update_command": "managed outside dashboard",
}
install_method = detect_install_method(PROJECT_ROOT)
if install_method == "docker":
message = format_docker_update_message()
@@ -2264,6 +2344,20 @@ async def check_hermes_update(force: bool = False):
desktop's remote update overlay renders this as "what's
changed". Additive: existing consumers ignore it.
"""
if _dashboard_local_update_managed_externally():
return {
"install_method": "managed-runtime",
"current_version": __version__,
"behind": None,
"update_available": False,
"can_apply": False,
"update_command": "managed outside dashboard",
"message": (
"Hermes updates are managed outside this dashboard in "
"containerized environments."
),
}
install_method = detect_install_method(PROJECT_ROOT)
update_command = recommended_update_command_for_method(install_method)
@@ -5144,7 +5238,7 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
@app.get("/api/providers/oauth")
async def list_oauth_providers():
async def list_oauth_providers(profile: Optional[str] = None):
"""Enumerate every OAuth-capable LLM provider with current status.
Response shape (per provider):
@@ -5161,83 +5255,89 @@ async def list_oauth_providers():
expires_at ISO timestamp string or null
has_refresh_token bool
"""
providers = []
for p in _OAUTH_PROVIDER_CATALOG:
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
"id": p["id"],
"name": p["name"],
"flow": p["flow"],
"cli_command": p["cli_command"],
"docs_url": p["docs_url"],
"disconnect_hint": disconnect_hint,
"disconnectable": disconnect_hint is None,
"status": status,
})
return {"providers": providers}
with _profile_scope(profile):
providers = []
for p in _OAUTH_PROVIDER_CATALOG:
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
"id": p["id"],
"name": p["name"],
"flow": p["flow"],
"cli_command": p["cli_command"],
"docs_url": p["docs_url"],
"disconnect_hint": disconnect_hint,
"disconnectable": disconnect_hint is None,
"status": status,
})
return {"providers": providers}
@app.delete("/api/providers/oauth/{provider_id}")
async def disconnect_oauth_provider(provider_id: str, request: Request):
async def disconnect_oauth_provider(
provider_id: str,
request: Request,
profile: Optional[str] = None,
):
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
_require_token(request)
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider: {provider_id}. "
f"Available: {', '.join(sorted(catalog_by_id))}",
)
with _profile_scope(profile):
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider: {provider_id}. "
f"Available: {', '.join(sorted(catalog_by_id))}",
)
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
if disconnect_hint:
raise HTTPException(
status_code=400,
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
)
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
# The separate claude-code catalog row is external/read-only and rejected
# above so we never pretend to remove ~/.claude/* credentials owned by the CLI.
if provider_id == "anthropic":
cleared = False
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
cleared = True
except Exception:
pass
# Also clear the credential pool entry if present.
try:
from hermes_cli.auth import clear_provider_auth
cleared = clear_provider_auth("anthropic") or cleared
except Exception:
pass
_log.info("oauth/disconnect: %s", provider_id)
return {"ok": bool(cleared), "provider": provider_id}
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
# The separate claude-code catalog row is external/read-only and rejected
# above so we never pretend to remove ~/.claude/* credentials owned by the CLI.
if provider_id == "anthropic":
cleared = False
try:
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
if _HERMES_OAUTH_FILE.exists():
_HERMES_OAUTH_FILE.unlink()
cleared = True
except Exception:
pass
# Also clear the credential pool entry if present.
try:
from hermes_cli.auth import clear_provider_auth
cleared = clear_provider_auth("anthropic") or cleared
except Exception:
pass
_log.info("oauth/disconnect: %s", provider_id)
return {"ok": bool(cleared), "provider": provider_id}
try:
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
cleared = clear_provider_auth(provider_id)
if provider_id == "nous":
invalidate_nous_auth_status_cache()
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
return {"ok": bool(cleared), "provider": provider_id}
except Exception as e:
_log.exception("disconnect %s failed", provider_id)
raise HTTPException(status_code=500, detail=str(e))
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
cleared = clear_provider_auth(provider_id)
if provider_id == "nous":
invalidate_nous_auth_status_cache()
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
return {"ok": bool(cleared), "provider": provider_id}
except Exception as e:
_log.exception("disconnect %s failed", provider_id)
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------------------------------------------------------
@@ -5319,13 +5419,32 @@ def _gc_oauth_sessions() -> None:
_oauth_sessions.pop(sid, None)
def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]:
def _oauth_profile_name(profile: Optional[str]) -> Optional[str]:
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
return None
return requested
def _validate_oauth_profile(profile: Optional[str]) -> None:
profile_name = _oauth_profile_name(profile)
if profile_name:
_resolve_profile_dir(profile_name)
def _new_oauth_session(
provider_id: str,
flow: str,
profile: Optional[str] = None,
) -> tuple[str, Dict[str, Any]]:
"""Create + register a new OAuth session, return (session_id, session_dict)."""
sid = secrets.token_urlsafe(16)
profile_name = _oauth_profile_name(profile)
sess = {
"session_id": sid,
"provider": provider_id,
"flow": flow,
"profile": profile_name,
"created_at": time.time(),
"status": "pending", # pending | approved | denied | expired | error
"error_message": None,
@@ -5335,6 +5454,17 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]
return sid, sess
def _oauth_session_profile(
session_id: str,
fallback: Optional[str] = None,
) -> Optional[str]:
"""Return the profile that owns an OAuth session, if one was provided."""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
profile = sess.get("profile") if sess else None
return profile or _oauth_profile_name(fallback)
def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
"""Persist Anthropic PKCE creds to both Hermes file AND credential pool.
@@ -5402,12 +5532,12 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
_log.warning("anthropic pool add (dashboard) failed: %s", e)
def _start_anthropic_pkce() -> Dict[str, Any]:
def _start_anthropic_pkce(profile: Optional[str] = None) -> Dict[str, Any]:
"""Begin PKCE flow. Returns the auth URL the UI should open."""
if not _ANTHROPIC_OAUTH_AVAILABLE:
raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)")
verifier, challenge = _generate_pkce_pair()
sid, sess = _new_oauth_session("anthropic", "pkce")
sid, sess = _new_oauth_session("anthropic", "pkce", profile=profile)
sess["verifier"] = verifier
sess["state"] = verifier # Anthropic round-trips verifier as state
params = {
@@ -5429,7 +5559,11 @@ def _start_anthropic_pkce() -> Dict[str, Any]:
}
def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
def _submit_anthropic_pkce(
session_id: str,
code_input: str,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""Exchange authorization code for tokens. Persists on success."""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
@@ -5483,7 +5617,8 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
try:
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
with _profile_scope(_oauth_session_profile(session_id, profile)):
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
except Exception as e:
with _oauth_sessions_lock:
sess["status"] = "error"
@@ -5495,7 +5630,10 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
return {"ok": True, "status": "approved"}
async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
async def _start_device_code_flow(
provider_id: str,
profile: Optional[str] = None,
) -> Dict[str, Any]:
"""Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
Calls the provider's device-auth endpoint via the existing CLI helpers,
@@ -5535,7 +5673,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
device_data, effective_scope = await asyncio.get_running_loop().run_in_executor(
None, _do_nous_device_request
)
sid, sess = _new_oauth_session("nous", "device_code")
sid, sess = _new_oauth_session("nous", "device_code", profile=profile)
sess["device_code"] = str(device_data["device_code"])
sess["interval"] = int(device_data["interval"])
sess["expires_at"] = time.time() + int(device_data["expires_in"])
@@ -5556,7 +5694,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
if provider_id == "openai-codex":
# Codex uses fixed OpenAI device-auth endpoints; reuse the helper.
sid, _ = _new_oauth_session("openai-codex", "device_code")
sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile)
# Use the helper but in a thread because it polls inline.
# We can't extract just the start step without refactoring auth.py,
# so we run the full helper in a worker and proxy the user_code +
@@ -5623,7 +5761,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
device_data = await asyncio.get_event_loop().run_in_executor(
None, _do_minimax_request
)
sid, sess = _new_oauth_session("minimax-oauth", "device_code")
sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile)
# The CLI flow names this `interval_ms` because MiniMax's
# `interval` field is in milliseconds (defensive default 2000ms
# in _minimax_poll_token).
@@ -5677,7 +5815,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
def _start_xai_loopback_flow() -> Dict[str, Any]:
def _start_xai_loopback_flow(profile: Optional[str] = None) -> Dict[str, Any]:
"""Begin the xAI loopback PKCE flow.
Binds the local callback server, builds the authorize URL, and spawns a
@@ -5716,7 +5854,7 @@ def _start_xai_loopback_flow() -> Dict[str, Any]:
pass
raise
sid, sess = _new_oauth_session("xai-oauth", "loopback")
sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile)
sess["server"] = server
sess["thread"] = thread
sess["callback_result"] = callback_result
@@ -5819,13 +5957,14 @@ def _xai_loopback_worker(session_id: str) -> None:
}
if _cancelled():
return
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
with _profile_scope(_oauth_session_profile(session_id)):
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
except Exception as exc:
_fail(f"xAI token exchange failed: {exc}")
return
@@ -5928,13 +6067,14 @@ def _nous_poller(session_id: str) -> None:
),
"expires_in": token_ttl,
}
full_state = refresh_nous_oauth_from_state(
auth_state,
timeout_seconds=15.0,
force_refresh=False,
)
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(full_state)
with _profile_scope(_oauth_session_profile(session_id)):
full_state = refresh_nous_oauth_from_state(
auth_state,
timeout_seconds=15.0,
force_refresh=False,
)
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(full_state)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: nous login completed (session=%s)", session_id)
@@ -6017,7 +6157,8 @@ def _minimax_poller(session_id: str) -> None:
).isoformat(),
"expires_in": expires_in_s,
}
_minimax_save_auth_state(auth_state)
with _profile_scope(_oauth_session_profile(session_id)):
_minimax_save_auth_state(auth_state)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: minimax login completed (session=%s)", session_id)
@@ -6130,10 +6271,11 @@ def _codex_full_login_worker(session_id: str) -> None:
from hermes_cli.auth import _save_codex_tokens
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
})
with _profile_scope(_oauth_session_profile(session_id)):
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
})
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
@@ -6147,10 +6289,15 @@ def _codex_full_login_worker(session_id: str) -> None:
@app.post("/api/providers/oauth/{provider_id}/start")
async def start_oauth_login(provider_id: str, request: Request):
async def start_oauth_login(
provider_id: str,
request: Request,
profile: Optional[str] = None,
):
"""Initiate an OAuth login flow. Token-protected."""
_require_token(request)
_gc_oauth_sessions()
_validate_oauth_profile(profile)
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid:
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
@@ -6168,12 +6315,12 @@ async def start_oauth_login(provider_id: str, request: Request):
# change for MiniMax). New PKCE providers must add their own
# start function and an explicit branch here.
if catalog_entry["flow"] == "pkce" and provider_id == "anthropic":
return _start_anthropic_pkce()
return _start_anthropic_pkce(profile=profile)
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id)
return await _start_device_code_flow(provider_id, profile=profile)
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
return await asyncio.get_running_loop().run_in_executor(
None, _start_xai_loopback_flow
None, _start_xai_loopback_flow, profile,
)
except HTTPException:
raise
@@ -6189,18 +6336,27 @@ class OAuthSubmitBody(BaseModel):
@app.post("/api/providers/oauth/{provider_id}/submit")
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
async def submit_oauth_code(
provider_id: str,
body: OAuthSubmitBody,
request: Request,
profile: Optional[str] = None,
):
"""Submit the auth code for PKCE flows. Token-protected."""
_require_token(request)
if provider_id == "anthropic":
return await asyncio.get_running_loop().run_in_executor(
None, _submit_anthropic_pkce, body.session_id, body.code,
None, _submit_anthropic_pkce, body.session_id, body.code, profile,
)
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
async def poll_oauth_session(provider_id: str, session_id: str):
async def poll_oauth_session(
provider_id: str,
session_id: str,
profile: Optional[str] = None,
):
"""Poll a session's status (no auth — read-only state).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
@@ -6223,7 +6379,11 @@ async def poll_oauth_session(provider_id: str, session_id: str):
@app.delete("/api/providers/oauth/sessions/{session_id}")
async def cancel_oauth_session(session_id: str, request: Request):
async def cancel_oauth_session(
session_id: str,
request: Request,
profile: Optional[str] = None,
):
"""Cancel a pending OAuth session. Token-protected."""
_require_token(request)
with _oauth_sessions_lock:
+124
View File
@@ -0,0 +1,124 @@
---
name: mpp-agent
description: Pay HTTP 402 APIs via Machine Payments Protocol (MPP).
version: 0.1.0
author: Teknium (teknium1), Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Payments, MPP, HTTP-402, Tempo, Stripe]
related_skills: [stripe-link-cli, stripe-projects]
---
# MPP Agent Skill
Wraps the Machine Payments Protocol (MPP, https://mpp.dev) clients so Hermes can pay for per-request API access against servers that respond with `HTTP 402 Payment Required`.
Three client options, all distributed via npm. Pick the lightest one that solves the user's need. Gated `[linux, macos]` while the broader payments tooling matures on Windows.
## When to Use
- A merchant API returns `HTTP 402` with a `www-authenticate` header — and the user wants to actually pay it, not just log the response.
- The user asks to "pay per request", "set up an agent wallet", "use Tempo / Privy / AgentCash", or wants to discover MPP-priced services.
- A Stripe Link spend has produced a Shared Payment Token (SPT) and the agent needs to attach it to the 402 challenge — in that flow, prefer `link-cli mpp pay` (see the `stripe-link-cli` skill).
## Choosing a client
| Tool | When | Setup |
|---|---|---|
| `link-cli` | User already has Stripe Link set up, or the 402 challenge advertises `method="stripe"` | see the `stripe-link-cli` skill |
| Tempo Wallet | MPP services with spend controls, service discovery | `tempo wallet login` |
| Privy Agent CLI | Multi-chain wallets, browser-based funding | `privy-agent-wallets login` |
| AgentCash | 300+ pre-priced APIs via one USDC.e balance | `npx agentcash onboard` |
| `mppx` | Dev + debugging, smallest dep surface | `npm install -g mppx` then `mppx account create` |
Default: if the user already has Stripe Link configured or the 402 challenge specifies `method="stripe"`, use `link-cli mpp pay` (the `stripe-link-cli` skill). Otherwise `mppx` for one-off paid calls and debugging, and Tempo Wallet when the user wants persistent spend controls.
## Prerequisites
- Node.js 20+ on `PATH`
- A funded wallet (Tempo / Privy / AgentCash) OR an `mppx` account
- For Tempo / Privy / AgentCash: follow their respective onboarding skills:
- `https://tempo.xyz/SKILL.md`
- `https://agents.privy.io/skill.md`
- `https://agentcash.dev/skill.md`
Use `web_extract` to fetch any of those SKILL.md files if the user picks one.
## Procedure (mppx, fastest path)
Run all commands through the `terminal` tool.
### 1. Install + create an account
```
npm install -g mppx
mppx account create
```
Store the resulting account credentials wherever the CLI tells you (the CLI writes them under its own config — do not paste them into the agent transcript).
### 2. Inspect the merchant's 402 challenge
If the user gives you a URL, probe it first to confirm it actually speaks MPP:
```
curl -i <url>
```
A real MPP 402 looks like:
```
HTTP/1.1 402 Payment Required
www-authenticate: tempo amount=0.1 currency=...
```
### 3. Pay the request
```
mppx <url>
```
For non-GET methods or request bodies:
```
mppx <url> --method POST --data '<json>'
```
`mppx` handles the 402 challenge/credential dance automatically and prints the merchant's actual response on success.
### 4. Verify the receipt
`mppx` attaches the receipt header automatically. To inspect:
```
mppx <url> -v
```
## Procedure (Tempo Wallet)
The Tempo Wallet skill at https://tempo.xyz/SKILL.md is the canonical reference; fetch it with `web_extract` and follow it. Headline:
```
tempo wallet login
tempo wallet pay <url>
```
Spend controls and service discovery live in the wallet UI at https://wallet.tempo.xyz.
## Pitfalls
- **`HTTP 402` without `method="stripe"` cannot be paid by Stripe Link.** If the challenge advertises only Tempo / other methods, use `mppx` (or whichever wallet matches) — Link will reject it. Conversely, if it advertises `method="stripe"`, prefer Link via the `stripe-link-cli` skill so the spend goes through the user's approved card.
- **Multiple challenges in one header.** `www-authenticate` may list several methods (e.g. `tempo, stripe`). The Link CLI's `mpp decode` will pick the Stripe one; `mppx` will pick Tempo. There's no single "right" client — pick by which wallet the user has funded.
- **Zero-amount challenges.** Some MPP endpoints charge `$0.00` and just want a proof credential. These work without a funded wallet. Don't refuse them as "broken."
- **Wallet keys never enter agent context.** All four clients store keys under their own config dirs (or generate per-session ephemeral keypairs, in Privy's case). Do not `cat`/`read_file` them.
- **Server-side MPP is a different skill.** If the user wants to ADD 402 to their own API, this skill is wrong — point them at https://mpp.dev/quickstart/server and the `mppx/nextjs` / `mppx/hono` / `mppx/express` / `mppx/elysia` middlewares. A dedicated `mpp-server` skill may land later.
## Verification
```
mppx --version && mppx account list
```
Exit code 0 means installed and an account exists.
@@ -0,0 +1,184 @@
---
name: stripe-link-cli
description: Agent payments via Stripe Link — cards, SPT, approvals.
version: 0.1.0
author: Teknium (teknium1), Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Payments, Stripe, Link, Checkout, MPP]
related_skills: [mpp-agent, stripe-projects]
---
# Stripe Link CLI Skill
Wraps [@stripe/link-cli](https://github.com/stripe/link-cli) so Hermes can complete purchases on the user's behalf using one-time-use virtual cards or Shared Payment Tokens (SPT). Every spend is gated by an in-app approval in the Link mobile/web app — Hermes cannot self-approve.
US-only at the moment (Link account requirement). Windows is not supported by the upstream CLI — this skill is gated `[linux, macos]`.
## When to Use
Trigger phrases:
- "buy X", "pay for X", "make a purchase", "complete checkout"
- "get me a card", "I need a payment method"
- "log in to Link", "connect my Link wallet"
- HTTP 402 response from a merchant API with `www-authenticate: ... method="stripe"`
If the user wants a paid API call (HTTP 402, no checkout form), the `card` path is wrong — use SPT via this same skill, or hand off to the `mpp-agent` skill.
## Prerequisites
- Node.js 20+ available on `PATH` (`node --version`)
- US-based (Link account requirement)
The Link account, payment method, and spend-approval app do NOT need to be set up before Hermes attempts to pay — the CLI walks the user through them on first run:
- A Link account at https://app.link.com — created/linked during first `link-cli` auth
- At least one payment method — added during first run at https://app.link.com/wallet
- The Link mobile/web app — opened to approve the first spend request when it's made
No env vars required — auth state is stored locally by the CLI under its own config directory.
## Install
Install once, globally:
```
npm install -g @stripe/link-cli
```
Or invoke ad-hoc via `npx @stripe/link-cli`. The skill below uses the installed `link-cli` form.
## How to Run
All commands run through the `terminal` tool. The CLI auto-detects non-TTY callers and emits compact `toon` output by default — fine for the model. Pass `--format json` if a step needs structured fields.
Discover commands: `link-cli --llms-full`.
Get a command's schema before invoking: `link-cli <command> --schema`.
## Procedure
### 1. Check / establish auth
```
link-cli auth status
```
If not authenticated, log in with a clear client name (this label shows in the user's Link app):
```
link-cli auth login --client-name "Hermes" --interval 5 --timeout 300
```
The `--interval`/`--timeout` form polls inline so the agent doesn't need to manage a `_next` step. Print the verification URL + phrase to the user and wait for the CLI to return.
**Do not proceed past this step until `auth status` confirms login.**
### 2. Evaluate the merchant before creating a spend request
Decide the credential type:
| Merchant surface | `--credential-type` |
|---|---|
| Standard web checkout form / Stripe Elements | `card` (default) |
| Returns HTTP 402 with `method="stripe"` in `www-authenticate` | `shared_payment_token` |
| Returns HTTP 402 without `method="stripe"` | unsupported — stop |
For 402 responses, do NOT decode the challenge manually. Pass the raw header:
```
link-cli mpp decode --challenge '<full WWW-Authenticate header>'
```
This validates the challenge and extracts the network ID + decoded request body.
### 3. List payment methods + shipping
```
link-cli payment-methods list
link-cli shipping-address list
```
Use the first entry unless the user specifies otherwise. The `id` from `payment-methods list` is the `--payment-method-id` in the next step.
### 4. Create the spend request
Confirm the final total with the user before issuing this command. Amounts are in cents.
```
link-cli spend-request create \
--payment-method-id <pm_id> \
--merchant-name "<name>" \
--merchant-url "<url>" \
--context "<one sentence: what is being purchased and why>" \
--amount <cents> \
--line-item "name:<item>,unit_amount:<cents>,quantity:1" \
--total "type:total,display_text:Total,amount:<cents>" \
--request-approval
```
For MPP merchants add `--credential-type shared_payment_token`.
`--request-approval` pings the user's Link app and polls until they approve or deny. The CLI exits non-zero on deny / timeout.
### 5. Retrieve the credential — SECURELY
**Do not print card details to stdout.** Use `--output-file` so the PAN never enters the agent's transcript or logs:
```
link-cli spend-request retrieve <lsrq_id> \
--include card \
--output-file /tmp/link-card.json \
--format json
```
The file is written with `0600` perms; stdout shows only redacted fields (brand, last4, expiry) plus a `card_output_file` path.
### 6. Use the credential
- For web checkout: hand the file path to the user, OR pass it to a browser-driving tool that fills the form directly from disk. Never `read_file` or `cat` the card file into the agent's reasoning context.
- For MPP merchants:
```
link-cli mpp pay <merchant-url> \
--spend-request-id <lsrq_id> \
--method POST \
--data '<json body>'
```
### 7. Clean up
Delete the card file as soon as the purchase is done:
```
rm -f /tmp/link-card.json
```
## Optional: run as an MCP server instead
`@stripe/link-cli --mcp` exposes the same commands as MCP tools over stdio. To register it with Hermes' native MCP:
```
hermes mcp add stripe-link --command "npx" --args "@stripe/link-cli --mcp"
```
Then `hermes mcp list` should show `stripe-link`. The same approval rules apply — MCP doesn't bypass the Link app approval step.
## Pitfalls
- **US-only.** Outside the US, `auth login` will fail. Tell the user, don't keep retrying.
- **Card PAN must never enter agent context.** Use `--output-file` every time. If you've already retrieved without it, immediately `link-cli auth logout` is not enough — the card is one-time-use but rotate hygiene matters.
- **`--request-approval` blocks until the user acts.** If the user is asleep, the CLI will hit its timeout. Set expectations.
- **Multi-step `_next` commands.** Some commands return `_next.command` that must be executed to continue. When in doubt, prefer the inline-polling flags (`--interval`/`--timeout`).
- **Output format defaults to `toon`** in non-TTY mode. Fine for prose, but if a downstream step needs to parse a specific field, pass `--format json`.
- **Don't default to `card`.** The merchant-evaluation step (Section 2) exists because picking the wrong credential type fails the purchase silently or leaks more data than needed.
## Verification
```
link-cli --version && link-cli auth status
```
Exit code 0 means installed and logged in.
@@ -0,0 +1,120 @@
---
name: stripe-projects
description: Provision SaaS services + sync creds via Stripe Projects.
version: 0.1.0
author: Teknium (teknium1), Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [Payments, Stripe, Projects, Provisioning, Infrastructure]
related_skills: [stripe-link-cli, mpp-agent]
---
# Stripe Projects Skill
Wraps the [Stripe Projects](https://projects.dev) CLI plugin so Hermes can provision SaaS services (Neon, Twilio, Vercel, etc.), generate and sync credentials into the user's `.env`, and manage billing across providers from one place.
Gated `[linux, macos]` while the broader payments cluster matures on Windows. The Stripe CLI itself is cross-platform; this gate is a posture for the cluster, not a hard limit.
## When to Use
Trigger phrases:
- "set up <provider>", "provision <Neon|Twilio|Vercel|...>", "create a database"
- "give me a <Postgres|Redis|Twilio number|...> for this project"
- "manage my stack credentials", "rotate this key", "upgrade my plan"
- "what providers can I add?"
If the user already has the service set up manually and just wants to use it, this skill is not the right entry point.
## Prerequisites
- Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install)
- Stripe Projects plugin installed
- A Stripe account, logged in via `stripe login`
## Install
macOS:
```
brew install stripe/stripe-cli/stripe
stripe plugin install projects
```
Linux: follow the platform-specific install at https://docs.stripe.com/stripe-cli/install, then:
```
stripe plugin install projects
```
## How to Run
All commands run through the `terminal` tool from inside the user's project directory (the CLI writes `.env` and `.projects/vault/vault.json` into the CWD).
## Procedure
### 1. Initialize the project
```
cd <project-root>
stripe projects init
```
This creates `.projects/vault/vault.json` (encrypted credential store) and prepares the project to receive providers.
### 2. Discover available providers
```
stripe projects catalog
```
Lists every provider Stripe Projects supports — databases, hosting, auth, AI, analytics, messaging, etc.
### 3. Add a service
```
stripe projects add <provider>/<service>
```
Examples:
- `stripe projects add neon/postgres`
- `stripe projects add twilio/sms`
- `stripe projects add runloop/sandbox`
The CLI provisions the service in the user's own account with the provider, generates credentials, syncs them into `.env`, and records the resource in the vault. The user may need to confirm a tier selection or pricing prompt.
### 4. Verify
```
stripe projects list
```
Should show the newly added provider and its `.env` keys.
### 5. Manage / upgrade / remove
```
stripe projects upgrade <provider> # tier change
stripe projects remove <provider> # deprovision
stripe projects rotate <provider> # rotate credentials
```
## Pitfalls
- **`.env` writes are real writes.** The CLI appends to whatever `.env` is in the project root. If the user's `.env` is gitignored (normal), the keys land safely; if not, this skill could be a credential-leak vector. Always check `.gitignore` first.
- **Per-project state.** `.projects/vault/vault.json` is per-project. Provisioning the same service in two different projects creates two separate resources — and two bills.
- **Billing happens on Stripe's side.** Tier prompts during `add`/`upgrade` are real charges; surface them to the user before confirming.
- **Provider availability changes.** The catalog grows; if a provider the user names isn't listed, `stripe projects catalog | grep <name>` first instead of failing the `add` call.
- **Credentials in vault are encrypted but `.env` is plaintext.** Standard `.env` hygiene applies — never commit it.
- **Removing a service does NOT always destroy the underlying resource.** Some providers leave a paused/dormant resource behind. Check the provider's own dashboard after `remove` for high-cost services (managed databases especially).
## Verification
```
stripe projects --version && stripe projects list
```
Exit code 0 inside an initialized project means the plugin is healthy.
+13 -10
View File
@@ -137,10 +137,11 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` |
| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect |
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"``telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer |
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer |
| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"``telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it.
**Resolver ladder** (first match wins):
@@ -158,13 +159,15 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys:
- **Single-operator** `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
- **Multi-user gateway** `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
- **Hybrid** `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct.
- **just me** `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor).
- **me + other people, pooled** `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer.
- **me + other people / only other people** `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans.
**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition.
Pick **[e]** at the prompt to set the three keys directly instead of going through the tree.
**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile.
### Memory & Recall
@@ -205,7 +208,7 @@ The Honcho session name determines which conversation bucket memory lands in. Re
Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions.
If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`.
If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`.
#### What each strategy produces
+231 -119
View File
@@ -41,22 +41,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
return False # already exists
# Clone settings from default block, override identity fields.
# Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases,
# runtimePeerPrefix) carry the operator's runtime-to-peer routing
# intent from #27371. Both pin keys are inherited because
# HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving
# the canonical key off this allowlist silently drops the pin on
# cloned profiles when the default uses the newer name.
# Identity-mapping keys (pinUserPeer, userPeerAliases, runtimePeerPrefix)
# carry the operator's runtime-to-peer routing intent from #27371.
new_block = {}
for key in ("recallMode", "writeFrequency", "sessionStrategy",
"sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel",
"dialecticDynamic", "dialecticMaxChars", "messageMaxChars",
"dialecticMaxInputChars", "saveMessages", "observation",
"pinPeerName", "pinUserPeer", "userPeerAliases",
"runtimePeerPrefix"):
"pinUserPeer", "userPeerAliases", "runtimePeerPrefix"):
val = default_block.get(key)
if val is not None:
new_block[key] = val
# Carry a legacy default-block pinPeerName forward under the canonical key.
if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None:
new_block["pinUserPeer"] = default_block["pinPeerName"]
# Inherit peer name from default
peer_name = default_block.get("peerName") or cfg.get("peerName")
@@ -371,15 +369,122 @@ def _resolve_effective_identity_mapping(
def _scrub_identity_mapping(hermes_host: dict) -> None:
"""Drop every peer-mapping key from the host block.
Called before the wizard writes a chosen shape so latent precedence
conflicts can't survive — e.g. a stray host ``pinUserPeer: false``
that would silently outrank a freshly written ``pinPeerName: true``
(host ``pinUserPeer`` is first in the resolver ladder).
Called before the wizard writes a chosen shape so a stale alias, prefix,
or pin from an earlier run can't bleed into the new mapping.
"""
for key in _IDENTITY_MAPPING_KEYS:
hermes_host.pop(key, None)
def _migrate_pin_key(block: dict) -> bool:
"""Rewrite a legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place.
``pinUserPeer`` wins over ``pinPeerName`` in the resolver, so setup writes
only the canonical form and migrates on touch to stop configs carrying
both. Returns True if the block changed.
"""
if "pinPeerName" not in block:
return False
legacy = block.pop("pinPeerName")
if "pinUserPeer" not in block:
block["pinUserPeer"] = legacy
return True
def _gateway_platforms() -> list[str] | None:
"""Connected gateway platforms, or None if undetectable.
Identity mapping only affects gateway runtime users, so setup gates the
whole step on this. Best-effort and dependency-free: the memory plugin
must not hard-depend on the gateway package, so the import is lazy and
guarded (matching the idiom hermes_cli already uses for gateway refs).
"""
try:
from gateway.config import load_gateway_config
return [p.value for p in load_gateway_config().get_connected_platforms()]
except Exception:
return None
def _collect_operator_aliases(existing: dict, peer_target: str) -> dict:
"""Prompt for the operator's per-platform runtime IDs, aliasing each to
``peer_target``. Existing entries are preserved."""
aliases = dict(existing)
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
("Telegram UID", "e.g. 7654321"),
("Discord snowflake", "e.g. 491827364"),
("Slack user ID", "e.g. U04ABCDEF"),
("Matrix MXID", "e.g. @you:matrix.org"),
):
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
if entered:
aliases[entered] = peer_target
return aliases
def _apply_runtime_prefix(
hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str
) -> None:
"""Write a host-level runtimePeerPrefix only when it diverges from an
inherited root value; otherwise let the root cascade stand."""
new_prefix = _prompt(label, default=current_prefix or "").strip()
if new_prefix and not (prefix_from_root and new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = new_prefix
def _echo_identity_mapping(hermes_host: dict) -> None:
"""Show the resulting keys so the operator can verify what was written."""
aliases = hermes_host.get("userPeerAliases")
prefix = hermes_host.get("runtimePeerPrefix")
print(" resolved →")
print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}")
print(f" userPeerAliases = {aliases if aliases else '{}'}")
print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}")
def _configure_raw_identity_mapping(
hermes_host: dict,
current_pin: bool,
current_aliases: dict,
current_prefix: str,
aliases_from_root: bool,
prefix_from_root: bool,
) -> None:
"""Power-user escape hatch: set the three resolver knobs directly."""
print("\n Raw identity-mapping keys (resolver tries them top-down):")
pin_in = _prompt(
"pinUserPeer — pin all gateway users to your peer? (true/false)",
default=str(bool(current_pin)).lower(),
).strip().lower()
pin = pin_in in {"true", "t", "yes", "y", "1"}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = pin
if pin:
return
aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):")
while True:
entry = _prompt(" alias", default="").strip()
if not entry:
break
if "=" in entry:
rid, peer = (p.strip() for p in entry.split("=", 1))
if rid and peer:
aliases[rid] = peer
if aliases:
hermes_host["userPeerAliases"] = aliases
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"runtimePeerPrefix — namespace for unknown IDs (blank for none)",
)
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
suffix = f" [{default}]" if default else ""
sys.stdout.write(f" {label}{suffix}: ")
@@ -446,6 +551,10 @@ def cmd_setup(args) -> None:
hosts = cfg.setdefault("hosts", {})
hermes_host = hosts.setdefault(_host_key(), {})
# Canonicalize any legacy pinPeerName before detection/writes.
_migrate_pin_key(cfg)
_migrate_pin_key(hermes_host)
# --- 1. Cloud or local? ---
print(" Deployment:")
print(" cloud -- Honcho cloud (api.honcho.dev)")
@@ -545,18 +654,15 @@ def cmd_setup(args) -> None:
if new_workspace:
hermes_host["workspace"] = new_workspace
# --- 3b. Deployment shape ---
# Determines how runtime user identities (Telegram UIDs, Discord
# snowflakes, etc.) map to Honcho peers in gateway sessions. Three
# shapes cover the realistic deployments; each writes a different
# combination of pinPeerName / userPeerAliases / runtimePeerPrefix.
# See plugins/memory/honcho/README.md for the resolver ladder.
# --- 3b. Gateway identity mapping ---
# These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...),
# the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP
# sessions have no runtime ID and fall through to peerName, so the step is
# moot off-gateway — gate it behind detection.
#
# Detection must mirror the gateway resolver: root-level config and
# ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level)
# both affect effective routing, so reading host-only fields would
# mis-classify a profile that inherits its mapping from root or uses
# the newer canonical key.
# Detection mirrors the gateway resolver: root-level config and the
# canonical ``pinUserPeer`` both affect routing, so host-only reads would
# mis-classify a profile that inherits its mapping from root.
(
current_pin,
current_aliases,
@@ -572,103 +678,109 @@ def cmd_setup(args) -> None:
else:
current_shape = "multi"
print("\n Deployment shape (how gateway users map to peers):")
print(" single -- all platforms route to your peer (recommended for personal use)")
print(" multi -- each platform user gets their own peer (multi-user bots)")
print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer")
print(" skip -- don't touch identity-mapping config")
new_shape = _prompt("Deployment shape", default=current_shape).strip().lower()
# Transitioning single → multi orphans the peerName pool for runtime users
# (their resolved peers go from peerName to runtime-derived IDs with empty
# history). Steer the operator toward hybrid so their own continuity is
# preserved via alias mappings.
if current_shape == "single" and new_shape == "multi":
peer_target = hermes_host.get("peerName") or current_peer or "user"
print(
f"\n ⚠ Switching from single to multi will orphan memory accumulated\n"
f" under peer '{peer_target}'. Existing runtime users (Telegram,\n"
f" Discord, etc.) will resolve to fresh, empty peers."
)
print(" To keep your own continuity, choose 'hybrid' and alias your\n"
" runtime IDs back to peerName.")
confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower()
if confirm in {"hybrid", "h"}:
new_shape = "hybrid"
elif confirm not in {"yes", "y"}:
new_shape = "skip"
# Each shape branch scrubs every peer-mapping key before writing its own,
# so a stale ``pinUserPeer`` left behind by an earlier setup run can't
# outrank the freshly written ``pinPeerName`` via host-level precedence.
if new_shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = True
print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.")
elif new_shape == "multi":
# Preserve operator-curated, host-level aliases so multi → multi
# re-runs don't drop them. Root-sourced aliases are left to
# cascade naturally and are NOT copied down into the host.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = False
# Do NOT auto-write ``userPeerAliases: {}``: an empty host map
# would override any root-level ``userPeerAliases`` the operator
# set as a cross-host baseline, silently disabling those aliases.
# Absence is the right "no host opinion" signal.
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
# Only write a host-level prefix when the operator typed one that
# diverges from the inherited root value; otherwise let the root
# cascade continue unmodified.
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.")
elif new_shape == "hybrid":
# Hybrid encodes operator intent at the host level: collect existing
# entries (host or root) so the wizard never silently drops a known
# alias, then write the combined map. Materialising root entries
# into the host is the right move here — once the operator answers
# the alias prompts for a host, they're declaring "this host owns
# the mapping".
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinPeerName"] = False
peer_target = hermes_host.get("peerName") or current_peer or "user"
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
("Telegram UID", "e.g. 86701400"),
("Discord snowflake", "e.g. 491827364"),
("Slack user ID", "e.g. U04ABCDEF"),
("Matrix MXID", "e.g. @you:matrix.org"),
):
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
if entered:
existing_aliases[entered] = peer_target
if existing_aliases:
hermes_host["userPeerAliases"] = existing_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.")
elif new_shape == "skip":
pass # leave config untouched
gw_platforms = _gateway_platforms()
if gw_platforms is None:
print("\n Gateway identity mapping routes platform users to memory peers.")
run_mapping = _prompt(
"Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)",
default="n",
).strip().lower() in {"y", "yes"}
elif not gw_platforms:
print("\n No gateway platforms connected — identity mapping only affects")
print(" gateway users, so this step doesn't apply here.")
run_mapping = _prompt(
"Configure gateway mapping anyway? (y/N)", default="n",
).strip().lower() in {"y", "yes"}
else:
print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.")
print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}")
run_mapping = True
if run_mapping:
peer_target = hermes_host.get("peerName") or current_peer or "user"
default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3")
print("\n How should gateway users map to memory peers?")
print(" [1] just me — every non-agent user collapses to your peer")
print(" [2] me + other people — keep mine pooled, others separate")
print(" [3] only other people — everyone gets their own peer")
print(" [s] skip (leave untouched) [e] edit raw keys")
choice = _prompt("Choice", default=default_choice).strip().lower()
if choice in {"2", "me+others", "both"}:
pooled = _prompt(
" Keep my own memory pooled across platforms? (Y/n)", default="y",
).strip().lower()
shape = "hybrid" if pooled in {"y", "yes", ""} else "multi"
elif choice in {"1", "me", "just-me"}:
shape = "single"
elif choice in {"3", "others"}:
shape = "multi"
elif choice in {"e", "edit", "raw"}:
shape = "raw"
else:
shape = "skip"
# Un-pinning a currently-pinned profile without aliasing strands the
# pooled peerName history; steer the operator toward pooling instead.
if current_pin and shape == "multi":
print(
f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n"
f" '{peer_target}'. Existing gateway users resolve to fresh,\n"
f" empty peers."
)
confirm = _prompt(
" Pool my own memory instead (alias my IDs to peerName)? (Y/n)",
default="y",
).strip().lower()
if confirm in {"y", "yes", ""}:
shape = "hybrid"
# Each branch scrubs every peer-mapping key first so a stale alias,
# prefix, or pin from an earlier run starts clean.
if shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = True
print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).")
_echo_identity_mapping(hermes_host)
elif shape == "multi":
# Preserve operator-curated host-level aliases across multi → multi
# re-runs. Root-sourced aliases cascade naturally and are NOT
# copied down — an empty host map would mask a root baseline.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
)
print(" Each gateway user → own peer.")
_echo_identity_mapping(hermes_host)
elif shape == "hybrid":
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
merged = _collect_operator_aliases(existing_aliases, peer_target)
if merged:
hermes_host["userPeerAliases"] = merged
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
)
print(f" Your runtime IDs → '{peer_target}', others → own peer.")
_echo_identity_mapping(hermes_host)
elif shape == "raw":
_configure_raw_identity_mapping(
hermes_host, current_pin, current_aliases, current_prefix,
aliases_from_root, prefix_from_root,
)
_echo_identity_mapping(hermes_host)
else: # skip
print(" Identity mapping left untouched.")
# --- 4. Observation mode ---
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")
+100 -24
View File
@@ -96,6 +96,9 @@ class MattermostAdapter(BasePlatformAdapter):
or os.getenv("MATTERMOST_REPLY_MODE", "off")
).lower()
self._last_post_status: Optional[int] = None
self._last_post_error: str = ""
# Dedup cache (prevent reprocessing)
self._dedup = MessageDeduplicator()
@@ -130,20 +133,79 @@ class MattermostAdapter(BasePlatformAdapter):
"""POST /api/v4/{path} with JSON body."""
import aiohttp
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
self._last_post_status = None
self._last_post_error = ""
try:
async with self._session.post(
url, headers=self._headers(), json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
self._last_post_status = resp.status
if resp.status >= 400:
body = await resp.text()
self._last_post_error = body or ""
logger.error("MM API POST %s%s: %s", path, resp.status, body[:200])
return {}
return await resp.json()
except aiohttp.ClientError as exc:
self._last_post_error = str(exc)
logger.error("MM API POST %s network error: %s", path, exc)
return {}
async def _thread_root_for_send(
self,
reply_to: Optional[str],
metadata: Optional[Dict[str, Any]],
) -> Optional[str]:
"""Resolve the Mattermost root_id from reply_to or metadata."""
if self._reply_mode != "thread":
return None
candidate = reply_to
if not candidate and isinstance(metadata, dict):
candidate = metadata.get("thread_id") or metadata.get("root_id")
if not candidate:
return None
return await self._resolve_root_id(str(candidate))
def _last_post_failure_is_broken_thread_root(self) -> bool:
"""Return True only for clear invalid/missing Mattermost thread roots."""
if self._last_post_status not in {400, 404}:
return False
body = (self._last_post_error or "").lower()
if not body:
return False
rootish = any(marker in body for marker in ("root_id", "rootid", "root id", "thread", "post"))
broken = any(marker in body for marker in ("invalid", "not found", "does not exist", "missing"))
return rootish and broken
async def _post_preserving_thread(
self,
chat_id: str,
payload: Dict[str, Any],
metadata: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""Post once, optionally falling back flat for final notify content."""
data = await self._api_post("posts", payload)
if data or "root_id" not in payload:
return data
if not (isinstance(metadata, dict) and metadata.get("notify")):
return data
if not self._last_post_failure_is_broken_thread_root():
return data
flat_payload = dict(payload)
flat_payload.pop("root_id", None)
original = str(flat_payload.get("message") or "")
flat_payload["message"] = (
"⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n"
+ original
).strip()
logger.warning(
"Mattermost: falling back to flat channel delivery for notify-worthy post in %s",
chat_id,
)
return await self._api_post("posts", flat_payload)
async def _api_put(
self, path: str, payload: Dict[str, Any]
) -> Dict[str, Any]:
@@ -286,14 +348,12 @@ class MattermostAdapter(BasePlatformAdapter):
"channel_id": chat_id,
"message": chunk,
}
# Thread support: reply_to is the root post ID.
if reply_to and self._reply_mode == "thread":
# Ensure root_id points to the thread root, not a reply.
# Mattermost rejects non-root post IDs as root_id.
resolved_root = await self._resolve_root_id(reply_to)
# Thread support: reply_to or metadata["thread_id"] is the root post ID.
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to create post")
last_id = data["id"]
@@ -346,7 +406,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Download an image and upload it as a file attachment."""
return await self._send_url_as_file(
chat_id, image_url, caption, reply_to, "image"
chat_id, image_url, caption, reply_to, "image", metadata
)
async def send_image_file(
@@ -359,7 +419,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload a local image file."""
return await self._send_local_file(
chat_id, image_path, caption, reply_to
chat_id, image_path, caption, reply_to, metadata=metadata
)
async def send_document(
@@ -373,7 +433,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload a local file as a document."""
return await self._send_local_file(
chat_id, file_path, caption, reply_to, file_name
chat_id, file_path, caption, reply_to, file_name, metadata
)
async def send_voice(
@@ -386,7 +446,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload an audio file."""
return await self._send_local_file(
chat_id, audio_path, caption, reply_to
chat_id, audio_path, caption, reply_to, metadata=metadata
)
async def send_video(
@@ -399,7 +459,7 @@ class MattermostAdapter(BasePlatformAdapter):
) -> SendResult:
"""Upload a video file."""
return await self._send_local_file(
chat_id, video_path, caption, reply_to
chat_id, video_path, caption, reply_to, metadata=metadata
)
def format_message(self, content: str) -> str:
@@ -423,12 +483,13 @@ class MattermostAdapter(BasePlatformAdapter):
caption: Optional[str],
reply_to: Optional[str],
kind: str = "file",
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download a URL and upload it as a file attachment."""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
import aiohttp
@@ -446,7 +507,7 @@ class MattermostAdapter(BasePlatformAdapter):
await asyncio.sleep(1.5 * (attempt + 1))
continue
if resp.status >= 400:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
file_data = await resp.read()
ct = resp.content_type or "application/octet-stream"
break
@@ -455,25 +516,26 @@ class MattermostAdapter(BasePlatformAdapter):
await asyncio.sleep(1.5 * (attempt + 1))
continue
logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
if file_data is None:
logger.warning("Mattermost: download returned no data for %s", url)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
file_id = await self._upload_file(chat_id, file_data, fname, ct)
if not file_id:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = await self._resolve_root_id(reply_to)
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
@@ -485,6 +547,7 @@ class MattermostAdapter(BasePlatformAdapter):
caption: Optional[str],
reply_to: Optional[str],
file_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file and attach it to a post."""
import mimetypes
@@ -509,10 +572,11 @@ class MattermostAdapter(BasePlatformAdapter):
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = await self._resolve_root_id(reply_to)
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
@@ -596,11 +660,14 @@ class MattermostAdapter(BasePlatformAdapter):
"message": "\n".join(caption_parts),
"file_ids": file_ids,
}
resolved_root = await self._thread_root_for_send(None, metadata)
if resolved_root:
payload["root_id"] = resolved_root
logger.info(
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
len(file_ids), chunk_idx + 1, len(chunks),
)
data = await self._api_post("posts", payload)
data = await self._post_preserving_thread(chat_id, payload, metadata)
if not data or "id" not in data:
logger.warning("Mattermost: multi-image post failed, falling back")
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
@@ -786,8 +853,16 @@ class MattermostAdapter(BasePlatformAdapter):
sender_id = post.get("user_id", "")
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
# Thread support: if the post is in a thread, use root_id.
# Thread support: if the post is in a thread, use root_id. In
# thread mode, top-level channel posts are valid roots for progress.
thread_id = post.get("root_id") or None
if (
not thread_id
and self._reply_mode == "thread"
and channel_type_raw != "D"
and post_id
):
thread_id = post_id
# Determine message type.
file_ids = post.get("file_ids") or []
@@ -849,6 +924,7 @@ class MattermostAdapter(BasePlatformAdapter):
user_id=sender_id,
user_name=sender_name,
thread_id=thread_id,
message_id=post_id,
)
# Per-channel ephemeral prompt
+10 -8
View File
@@ -54,8 +54,10 @@ hermes gateway start
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
`https://app.photon.codes/` for approval and stores the bearer token.
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the
project secret, and persist both.
3. **Provision the project secret** — mint a fresh project secret (the
dashboard reveals it only once) and persist it to `~/.hermes/.env` so the
sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no
separate enable step.
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
a user with that number already exists).
5. **Print the assigned iMessage line** — the number you text to reach your
@@ -75,7 +77,7 @@ Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
channel keeps its token), and the adapter reads them from the environment:
```bash
PHOTON_PROJECT_ID=<spectrumProjectId> # the SDK's projectId
PHOTON_PROJECT_ID=<projectId> # the SDK's projectId (same as the dashboard project id)
PHOTON_PROJECT_SECRET=<projectSecret>
```
@@ -89,8 +91,8 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
],
"photon_project": [
{
"dashboard_project_id": "<dashboard id>",
"spectrum_project_id": "<spectrumProjectId>",
"dashboard_project_id": "<project id>",
"spectrum_project_id": "<project id>",
"project_secret": "<projectSecret>",
"name": "Hermes Agent"
}
@@ -99,9 +101,9 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
}
```
> **Note on ids.** A Photon project has two identifiers: the dashboard `id`
> (used for management API calls) and the `spectrumProjectId` (what the SDK
> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id.
> **Note on ids.** A Photon project's dashboard id and its Spectrum project id
> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id`
> and `spectrum_project_id` keys in `auth.json` both hold that id.
## Configuration knobs
+40 -59
View File
@@ -3,29 +3,29 @@ Photon Dashboard API client + device-code login flow.
This module is pure Python it intentionally does not depend on
``spectrum-ts``. Every management-plane operation (login, find/create
project, enable Spectrum, rotate the project secret, register a user,
list the assigned iMessage line) talks to Photon's **Dashboard API** on a
single host, exactly like the official Photon CLI (``photon-hq/cli``):
project, rotate the project secret, register a user, list the assigned
iMessage line) talks to Photon's **Dashboard API** on a single host,
exactly like the official Photon CLI (``photon-hq/cli``):
Dashboard API https://app.photon.codes/api/...
OAuth 2.0 device flow, Bearer access token
A Photon project carries two distinct identifiers:
* ``id`` the Dashboard project id (used in API paths)
* ``spectrumProjectId`` the Spectrum Cloud project id, populated when
Spectrum is enabled on the project
A Photon project has a single identifier: the dashboard ``id`` *is* the
Spectrum Cloud project id. They used to diverge (a separate
``spectrumProjectId`` field), but the dashboard unified them every
project is created with matching ids and the pre-existing diverged rows
were backfilled so ``project.id == spectrumProjectId`` everywhere
(dashboard ENG-1582). Spectrum is always enabled and provisioned at
create-time, so there is no enable/toggle step anymore.
The ``spectrum-ts`` SDK (run by the Node sidecar) authenticates to Spectrum
Cloud with ``(spectrumProjectId, projectSecret)`` so the value we persist
as ``PHOTON_PROJECT_ID`` for the runtime is the **spectrumProjectId**, not
the Dashboard ``id``. The Dashboard ``id`` is kept only for management
calls.
Cloud with ``(id, projectSecret)`` the same ``id`` used in Dashboard API
paths which we persist as ``PHOTON_PROJECT_ID`` for the runtime.
Credential storage mirrors every other Hermes channel:
* runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` =
spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value``
project id, ``PHOTON_PROJECT_SECRET``) via ``save_env_value``
* management metadata -> ``~/.hermes/auth.json`` under
``credential_pool.photon`` (device token),
``credential_pool.photon_project`` (dashboard id, spectrum id, name), and
@@ -148,8 +148,8 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
Precedence: process env (``~/.hermes/.env`` is loaded into the gateway's
environment at startup) wins, then ``auth.json`` for offline / status
use. This is the pair the Node sidecar feeds to ``spectrum-ts`` the id
is the **spectrumProjectId**, not the Dashboard id.
use. This is the pair the Node sidecar feeds to ``spectrum-ts``; the id
is the unified project id (dashboard id == spectrumProjectId).
"""
env_id = os.getenv("PHOTON_PROJECT_ID")
env_sec = os.getenv("PHOTON_PROJECT_SECRET")
@@ -166,14 +166,26 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]:
def load_dashboard_project_id() -> Optional[str]:
"""Return the Dashboard project id (for management API calls)."""
"""Return the project id used for management API calls.
Post-unification the dashboard id and the Spectrum id are the same value,
so we prefer the stored ``spectrum_project_id``: for pre-backfill installs
the old ``dashboard_project_id`` is the diverged id that the unification
rewrote (it now 404s), while the Spectrum id always matches the live row.
Falls back to the legacy keys for older records.
"""
env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID")
if env_id:
return env_id
auth = _load_auth()
proj = auth.get("credential_pool", {}).get("photon_project") or []
if isinstance(proj, list) and proj:
return proj[0].get("dashboard_project_id") or proj[0].get("project_id")
entry = proj[0]
return (
entry.get("spectrum_project_id")
or entry.get("dashboard_project_id")
or entry.get("project_id")
)
return None
@@ -646,30 +658,23 @@ def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]:
return None
def get_project(token: str, project_id: str) -> Dict[str, Any]:
"""GET ``/api/projects/{id}`` — includes ``spectrum`` + ``spectrumProjectId``."""
if httpx is None:
raise RuntimeError("httpx is required for Photon")
url = f"{_dashboard_host()}/api/projects/{project_id}"
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
return resp.json() or {}
def create_project(
token: str,
*,
name: str = DEFAULT_PROJECT_NAME,
location: str = "United States",
) -> Dict[str, Any]:
"""POST ``/api/projects`` with ``spectrum: true`` and return ``{success, id}``."""
"""POST ``/api/projects`` and return ``{success, id}``.
Spectrum is always provisioned at create-time, so the request body no
longer carries a ``spectrum`` flag (the field was dropped from the API).
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon project creation")
url = f"{_dashboard_host()}/api/projects"
body: Dict[str, Any] = {
"name": name,
"location": location,
"spectrum": True,
"template": False,
"observability": False,
}
@@ -683,29 +688,6 @@ def create_project(
return data
def ensure_spectrum_enabled(token: str, project_id: str) -> Dict[str, Any]:
"""Enable Spectrum on the project if needed; return the project dict.
The dashboard exposes Spectrum as a toggle, so we only flip it when
``spectrum`` is currently false, then re-fetch to pick up the freshly
populated ``spectrumProjectId``.
"""
if httpx is None:
raise RuntimeError("httpx is required for Photon")
proj = get_project(token, project_id)
if not proj.get("spectrum"):
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/toggle"
resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
proj = get_project(token, project_id)
if not proj.get("spectrumProjectId"):
raise RuntimeError(
"Spectrum is enabled but the project has no spectrumProjectId yet — "
"retry in a moment, or enable Spectrum from the dashboard."
)
return proj
def regenerate_project_secret(token: str, project_id: str) -> str:
"""POST ``/api/projects/{id}/regenerate-secret`` → the new project secret.
@@ -1007,8 +989,9 @@ def print_credential_summary(emit: Any = print) -> None:
else "✗ missing (run `hermes photon setup`)"
)
sid, sec = load_project_credentials()
labels["spectrum_project_id"] = sid if sid else "✗ missing"
labels["dashboard_project_id"] = load_dashboard_project_id() or ""
# Dashboard id and Spectrum id are the same value now (ids unified), so
# there's a single project id to show.
labels["project_id"] = sid if sid else "✗ missing"
labels["project_key"] = "✓ stored" if sec else "✗ missing"
phone, assigned = load_user_numbers()
labels["phone_number"] = (
@@ -1022,8 +1005,7 @@ def print_credential_summary(emit: Any = print) -> None:
"Photon iMessage status",
"──────────────────────",
" device token : " + labels["device_token"],
" dashboard project : " + labels["dashboard_project_id"],
" spectrum project id : " + labels["spectrum_project_id"],
" project id : " + labels["project_id"],
" project secret : " + labels["project_key"],
" my number : " + labels["phone_number"],
" assigned number : " + labels["assigned_phone_number"],
@@ -1039,7 +1021,7 @@ def credential_summary() -> Dict[str, str]:
else "✗ missing (run `hermes photon setup`)"
)
def _present_spectrum_id() -> str:
def _present_project_id() -> str:
sid, _sec = load_project_credentials()
return sid or "✗ missing"
@@ -1057,8 +1039,7 @@ def credential_summary() -> Dict[str, str]:
return {
"device_token": _present_token(),
"dashboard_project_id": load_dashboard_project_id() or "",
"spectrum_project_id": _present_spectrum_id(),
"project_id": _present_project_id(),
"project_key": _present_secret(),
"phone_number": _present_phone(),
"assigned_phone_number": _present_assigned_phone(),
+8 -10
View File
@@ -164,16 +164,14 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print("could not resolve a Photon project id", file=sys.stderr)
return 1
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
# 3. Rotate the project secret and persist creds (runtime -> ~/.hermes/.env,
# ids -> auth.json). Spectrum is always enabled and provisioned at
# create-time, and the dashboard project id *is* the Spectrum project id
# (ids unified), so there's nothing to enable — the id we already have is
# the Spectrum id.
try:
print("[3/5] Enabling Spectrum and provisioning credentials...")
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
spectrum_id = proj.get("spectrumProjectId")
if not spectrum_id:
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
return 1
spectrum_id = str(spectrum_id)
print("[3/5] Provisioning Spectrum credentials...")
spectrum_id = dashboard_id
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
photon_auth.store_project_credentials(
spectrum_project_id=spectrum_id,
@@ -182,7 +180,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
name=name,
)
# spectrum_id is an opaque non-secret id; safe to show.
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
print(f" ✓ Spectrum ready (project id {spectrum_id}) — secret saved")
except Exception as e:
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
return 1
+7 -1
View File
@@ -1411,10 +1411,15 @@ class AIAgent:
def _summarize_background_review_actions(
review_messages: List[Dict],
prior_snapshot: List[Dict],
notification_mode: str = "on",
) -> List[str]:
"""Forwarder — see ``agent.background_review.summarize_background_review_actions``."""
from agent.background_review import summarize_background_review_actions
return summarize_background_review_actions(review_messages, prior_snapshot)
return summarize_background_review_actions(
review_messages,
prior_snapshot,
notification_mode=notification_mode,
)
def _spawn_background_review(
self,
@@ -5140,6 +5145,7 @@ class AIAgent:
acp_command=function_args.get("acp_command"),
acp_args=function_args.get("acp_args"),
role=function_args.get("role"),
background=function_args.get("background"),
parent_agent=self,
)
+2
View File
@@ -49,6 +49,7 @@ AUTHOR_MAP = {
"rio.jeong@thebytesize.ai": "rio-jeong",
"yehaotian@xuanshudeMac-mini.local": "ArcanePivot",
"dbeyer7@gmail.com": "benegessarit",
"264773240+MrDiamondBallz@users.noreply.github.com": "MrDiamondBallz",
"kenmege@yahoo.com": "Kenmege",
"tianying.x@eukarya.io": "xtymac",
"dkobi16@gmail.com": "Diyoncrz18",
@@ -89,6 +90,7 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"evansrory@gmail.com": "zimigit2020",
"237263164+ft-ioxcs@users.noreply.github.com": "ft-ioxcs",
"tharushkadinujaya05@gmail.com": "0xneobyte",
"138671361+Veritas-7@users.noreply.github.com": "Veritas-7",
+31
View File
@@ -1653,6 +1653,37 @@ class TestAuxiliaryFallbackLayering:
exc.status_code = 402
return exc
def test_auto_provider_uses_task_then_main_chain_before_builtin_chain(self, monkeypatch):
"""Auto aux call failures try per-task then top-level fallback before built-ins."""
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
main_chain_client = MagicMock()
main_chain_client.chat.completions.create.return_value = MagicMock(choices=[
MagicMock(message=MagicMock(content="from main fallback chain"))
])
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "qwen/qwen3.5-122b-a10b")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")) as mock_task_chain, \
patch("agent.auxiliary_client._try_main_fallback_chain",
return_value=(main_chain_client, "inclusionai/ring-2.6-1t:free", "openrouter")) as mock_main_chain, \
patch("agent.auxiliary_client._try_payment_fallback") as mock_builtin_chain:
result = call_llm(
task="title_generation",
messages=[{"role": "user", "content": "hello"}],
)
assert main_chain_client.chat.completions.create.called
mock_task_chain.assert_called_once_with(
"title_generation", "auto", reason="payment error")
mock_main_chain.assert_called_once_with(
"title_generation", "auto", reason="payment error")
mock_builtin_chain.assert_not_called()
def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog):
"""When a user has fallback_chain configured, it's tried BEFORE the main agent model."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
+58
View File
@@ -118,6 +118,64 @@ class TestResolveAutoMainFirst:
assert client is chain_client
assert model == "google/gemini-3-flash-preview"
def test_main_unavailable_uses_task_fallback_chain_before_builtin_chain(self):
"""Auto aux resolution honors auxiliary.<task>.fallback_chain before built-ins."""
task_client = MagicMock()
with patch(
"agent.auxiliary_client._read_main_provider", return_value="nvidia",
), patch(
"agent.auxiliary_client._read_main_model", return_value="qwen/qwen3.5-122b-a10b",
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(None, None), # main provider has no client
), patch(
"agent.auxiliary_client._try_configured_fallback_chain",
return_value=(task_client, "task-free-model", "fallback_chain[0](openrouter)"),
) as mock_task_chain, patch(
"agent.auxiliary_client._try_main_fallback_chain",
) as mock_main_chain, patch(
"agent.auxiliary_client._try_openrouter",
) as mock_openrouter:
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto(task="title_generation")
assert client is task_client
assert model == "task-free-model"
mock_task_chain.assert_called_once_with(
"title_generation", "nvidia", reason="main provider unavailable")
mock_main_chain.assert_not_called()
mock_openrouter.assert_not_called()
def test_main_unavailable_uses_main_fallback_chain_before_builtin_chain(self):
"""Auto aux resolution honors top-level fallback_providers before built-ins."""
main_fallback_client = MagicMock()
with patch(
"agent.auxiliary_client._read_main_provider", return_value="nvidia",
), patch(
"agent.auxiliary_client._read_main_model", return_value="qwen/qwen3.5-122b-a10b",
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(None, None), # main provider has no client
), patch(
"agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, ""),
), patch(
"agent.auxiliary_client._try_main_fallback_chain",
return_value=(main_fallback_client, "inclusionai/ring-2.6-1t:free", "openrouter"),
) as mock_main_chain, patch(
"agent.auxiliary_client._try_openrouter",
) as mock_openrouter:
from agent.auxiliary_client import _resolve_auto
client, model = _resolve_auto(task="title_generation")
assert client is main_fallback_client
assert model == "inclusionai/ring-2.6-1t:free"
mock_main_chain.assert_called_once_with(
"title_generation", "nvidia", reason="main provider unavailable")
mock_openrouter.assert_not_called()
def test_no_main_config_uses_chain_directly(self):
"""No main provider configured → skip step 1, use chain (no regression)."""
chain_client = MagicMock()
+251 -45
View File
@@ -162,11 +162,26 @@ def _has_unpushed_commits(worktree_path, timeout=10):
return True
def _is_dirty(wt_path, timeout=10):
"""Test version of the worktree dirty-check helper (fail-safe True)."""
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=timeout, cwd=wt_path,
)
if result.returncode != 0:
return True
return bool(result.stdout.strip())
except Exception:
return True
def _cleanup_worktree(info):
"""Test version of _cleanup_worktree.
Preserves the worktree only if it has unpushed commits.
Dirty working tree alone is not enough to keep it.
Mirrors the cli.py contract: preserves the worktree if it has
unpushed commits OR uncommitted changes; only deletes the branch
after ``git worktree remove`` succeeded.
"""
wt_path = info["path"]
branch = info["branch"]
@@ -178,10 +193,16 @@ def _cleanup_worktree(info):
if _has_unpushed_commits(wt_path, timeout=10):
return False # Did not clean up — has unpushed commits
subprocess.run(
if _is_dirty(wt_path):
return False # Did not clean up — uncommitted changes
result = subprocess.run(
["git", "worktree", "remove", wt_path, "--force"],
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
if result.returncode != 0:
return False # Removal failed — keep the branch
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=repo_root,
@@ -283,17 +304,18 @@ class TestWorktreeCleanup:
assert result is True
assert not Path(info["path"]).exists()
def test_dirty_worktree_cleaned_when_no_unpushed(self, git_repo):
"""Dirty working tree without unpushed commits is cleaned up.
def test_dirty_worktree_preserved_on_cleanup(self, git_repo):
"""Dirty working tree is preserved even without unpushed commits.
Agent sessions typically leave untracked files / artifacts behind.
Since all real work is in pushed commits, these don't warrant
keeping the worktree.
Uncommitted changes may be work the user has not retrieved yet
cleanup must never destroy them.
"""
info = _setup_worktree(str(git_repo))
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
# Make uncommitted changes (untracked file)
# Make uncommitted changes (staged but uncommitted file)
(Path(info["path"]) / "new-file.txt").write_text("uncommitted")
subprocess.run(
["git", "add", "new-file.txt"],
@@ -301,10 +323,17 @@ class TestWorktreeCleanup:
)
# The git_repo fixture already has a fake remote ref so the initial
# commit is seen as "pushed". No unpushed commits → cleanup proceeds.
result = _cleanup_worktree(info)
assert result is True # Cleaned up despite dirty working tree
assert not Path(info["path"]).exists()
# commit is seen as "pushed" — only the dirty tree protects it.
cli_mod._cleanup_worktree(info)
assert Path(info["path"]).exists() # Preserved despite no unpushed commits
# Branch and lock are kept too
result = subprocess.run(
["git", "branch", "--list", info["branch"]],
capture_output=True, text=True, cwd=str(git_repo),
)
assert info["branch"] in result.stdout
assert cli_mod._worktree_is_locked(str(git_repo), info["path"]) is True
def test_worktree_with_unpushed_commits_kept(self, git_repo):
"""Worktree with unpushed commits is preserved."""
@@ -728,47 +757,224 @@ class TestStaleWorktreePruning:
assert not Path(info["path"]).exists()
def test_force_prunes_very_old_worktree(self, git_repo):
"""Worktrees older than 72h should be force-pruned regardless."""
"""Very old (>72h) CLEAN, unlocked, fully-pushed worktrees are pruned."""
import time
import cli as cli_mod
info = _setup_worktree(str(git_repo))
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
# Make an unpushed commit (would normally protect it)
(Path(info["path"]) / "work.txt").write_text("stale work")
subprocess.run(["git", "add", "work.txt"], cwd=info["path"], capture_output=True)
subprocess.run(
["git", "commit", "-m", "old agent work"],
cwd=info["path"], capture_output=True,
)
# _setup_worktree locks the worktree; unlock to simulate a worktree
# whose owning session released it (clean + unlocked + pushed).
assert cli_mod._unlock_worktree(str(git_repo), info["path"]) is True
# Make it very old (73h — beyond the 72h hard threshold)
# Make it very old (73h)
old_time = time.time() - (73 * 3600)
os.utime(info["path"], (old_time, old_time))
# Simulate the force-prune tier check
hard_cutoff = time.time() - (72 * 3600)
mtime = Path(info["path"]).stat().st_mtime
assert mtime <= hard_cutoff # Should qualify for force removal
# Actually remove it (simulates _prune_stale_worktrees force path)
branch_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, timeout=5, cwd=info["path"],
)
branch = branch_result.stdout.strip()
subprocess.run(
["git", "worktree", "remove", info["path"], "--force"],
capture_output=True, text=True, timeout=15, cwd=str(git_repo),
)
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=str(git_repo),
)
cli_mod._prune_stale_worktrees(str(git_repo))
assert not Path(info["path"]).exists()
# Branch should be gone too
result = subprocess.run(
["git", "branch", "--list", info["branch"]],
capture_output=True, text=True, cwd=str(git_repo),
)
assert info["branch"] not in result.stdout
class TestWorktreeLocking:
"""Test git-native worktree locks and the preserve-work contracts.
These tests exercise the REAL cli.py implementations (not the local
reimplementations above), matching the pattern in
test_worktree_security.py.
"""
def test_setup_worktree_locks(self, git_repo):
"""_setup_worktree leaves the new worktree locked."""
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
# Verify via git worktree list --porcelain: the stanza for this
# worktree must contain a "locked" line.
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, cwd=str(git_repo),
)
target = Path(info["path"]).resolve()
current = None
locked = False
for line in result.stdout.splitlines():
if line.startswith("worktree "):
current = Path(line[len("worktree "):].strip()).resolve()
elif line == "locked" or line.startswith("locked "):
if current == target:
locked = True
assert locked
assert cli_mod._worktree_is_locked(str(git_repo), info["path"]) is True
def test_unlock_worktree(self, git_repo):
"""_unlock_worktree releases the lock taken by _setup_worktree."""
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert cli_mod._worktree_is_locked(str(git_repo), info["path"]) is True
assert cli_mod._unlock_worktree(str(git_repo), info["path"]) is True
assert cli_mod._worktree_is_locked(str(git_repo), info["path"]) is False
def test_prune_skips_locked_very_old_clean_worktree(self, git_repo):
"""A locked worktree is never pruned, even >72h old and clean."""
import time
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
# Still locked from _setup_worktree; clean; fully pushed.
old_time = time.time() - (80 * 3600)
os.utime(info["path"], (old_time, old_time))
cli_mod._prune_stale_worktrees(str(git_repo))
assert Path(info["path"]).exists()
def test_prune_skips_old_dirty_unlocked_worktree(self, git_repo):
"""An old dirty worktree is not pruned even when unlocked."""
import time
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert cli_mod._unlock_worktree(str(git_repo), info["path"]) is True
# Uncommitted change (untracked file)
(Path(info["path"]) / "wip.txt").write_text("uncommitted work")
old_time = time.time() - (25 * 3600)
os.utime(info["path"], (old_time, old_time))
cli_mod._prune_stale_worktrees(str(git_repo))
assert Path(info["path"]).exists()
assert (Path(info["path"]) / "wip.txt").exists()
def test_prune_preserves_very_old_worktree_with_unpushed_commits(self, git_repo):
"""Unpushed commits protect a worktree at ANY age — the old >72h
force-remove tier is gone."""
import time
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert cli_mod._unlock_worktree(str(git_repo), info["path"]) is True
# Unpushed commit (clean tree afterwards)
(Path(info["path"]) / "work.txt").write_text("real work")
subprocess.run(["git", "add", "work.txt"], cwd=info["path"], capture_output=True)
subprocess.run(
["git", "commit", "-m", "agent work"],
cwd=info["path"], capture_output=True,
)
old_time = time.time() - (80 * 3600)
os.utime(info["path"], (old_time, old_time))
cli_mod._prune_stale_worktrees(str(git_repo))
assert Path(info["path"]).exists()
result = subprocess.run(
["git", "branch", "--list", info["branch"]],
capture_output=True, text=True, cwd=str(git_repo),
)
assert info["branch"] in result.stdout
def test_cleanup_preserves_dirty_worktree(self, git_repo):
"""_cleanup_worktree keeps a dirty worktree (untracked file)."""
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
(Path(info["path"]) / "scratch.txt").write_text("not yet committed")
cli_mod._cleanup_worktree(info)
assert Path(info["path"]).exists()
assert (Path(info["path"]) / "scratch.txt").exists()
def test_cleanup_removes_clean_locked_worktree(self, git_repo):
"""_cleanup_worktree unlocks then removes a clean, pushed worktree."""
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert cli_mod._worktree_is_locked(str(git_repo), info["path"]) is True
cli_mod._cleanup_worktree(info)
assert not Path(info["path"]).exists()
result = subprocess.run(
["git", "branch", "--list", info["branch"]],
capture_output=True, text=True, cwd=str(git_repo),
)
assert info["branch"] not in result.stdout
def test_branch_kept_when_worktree_remove_fails(self, git_repo, monkeypatch):
"""If `git worktree remove` fails, the branch must NOT be deleted."""
import subprocess as sp
import cli as cli_mod
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
real_run = sp.run
def fake_run(cmd, *args, **kwargs):
if (
isinstance(cmd, (list, tuple))
and list(cmd[:3]) == ["git", "worktree", "remove"]
):
return sp.CompletedProcess(
cmd, returncode=1, stdout="", stderr="simulated removal failure"
)
return real_run(cmd, *args, **kwargs)
monkeypatch.setattr(sp, "run", fake_run)
cli_mod._cleanup_worktree(info)
monkeypatch.undo()
# Worktree dir still present, branch NOT deleted
assert Path(info["path"]).exists()
result = subprocess.run(
["git", "branch", "--list", info["branch"]],
capture_output=True, text=True, cwd=str(git_repo),
)
assert info["branch"] in result.stdout
def test_worktree_is_locked_fail_safe(self, tmp_path):
"""_worktree_is_locked returns True (fail safe) on a bogus repo_root."""
import cli as cli_mod
bogus = tmp_path / "does-not-exist"
assert cli_mod._worktree_is_locked(str(bogus), str(bogus / "wt")) is True
# An existing directory that is not a git repo is also an error case
not_repo = tmp_path / "not-a-repo"
not_repo.mkdir()
assert cli_mod._worktree_is_locked(str(not_repo), str(not_repo / "wt")) is True
def test_worktree_is_dirty_fail_safe(self, tmp_path):
"""_worktree_is_dirty returns True (fail safe) on a bogus path."""
import cli as cli_mod
assert cli_mod._worktree_is_dirty(str(tmp_path / "missing")) is True
class TestEdgeCases:
+5 -5
View File
@@ -1498,7 +1498,7 @@ class TestAgentConfigSignatureUserId:
from gateway.run import GatewayRunner
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
sig_a = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
)
sig_b = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364"
@@ -1509,10 +1509,10 @@ class TestAgentConfigSignatureUserId:
from gateway.run import GatewayRunner
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
sig_1 = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
)
sig_2 = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400"
"claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321"
)
assert sig_1 == sig_2
@@ -1521,11 +1521,11 @@ class TestAgentConfigSignatureUserId:
runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"}
sig_a = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "",
user_id="86701400", user_id_alt="@igor_tg",
user_id="7654321", user_id_alt="@igor_tg",
)
sig_b = GatewayRunner._agent_config_signature(
"claude-sonnet-4", runtime, ["hermes-telegram"], "",
user_id="86701400", user_id_alt="@erosika_tg",
user_id="7654321", user_id_alt="@erosika_tg",
)
assert sig_a != sig_b
+2 -2
View File
@@ -832,7 +832,7 @@ class TestLoadGatewayConfig:
assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False
def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch):
def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@@ -842,7 +842,7 @@ class TestLoadGatewayConfig:
config = load_config()
assert config["telegram"]["extra"]["rich_messages"] is False
assert config["telegram"]["extra"]["rich_messages"] is True
def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
+60
View File
@@ -450,3 +450,63 @@ class TestCleanupProgress:
}
}
assert resolve_display_setting(config, "telegram", "cleanup_progress") is True, val
class TestToolProgressGrouping:
"""resolve_display_setting() for the tool_progress_grouping knob."""
def test_default_is_accumulate(self):
"""No config anywhere → global default 'accumulate'."""
from gateway.display_config import resolve_display_setting
assert (
resolve_display_setting({}, "telegram", "tool_progress_grouping")
== "accumulate"
)
def test_global_separate(self):
from gateway.display_config import resolve_display_setting
config = {"display": {"tool_progress_grouping": "separate"}}
assert (
resolve_display_setting(config, "discord", "tool_progress_grouping")
== "separate"
)
def test_platform_override_wins(self):
from gateway.display_config import resolve_display_setting
config = {
"display": {
"tool_progress_grouping": "accumulate",
"platforms": {"discord": {"tool_progress_grouping": "separate"}},
}
}
assert (
resolve_display_setting(config, "discord", "tool_progress_grouping")
== "separate"
)
# Other platforms still get the global value.
assert (
resolve_display_setting(config, "telegram", "tool_progress_grouping")
== "accumulate"
)
def test_invalid_value_falls_back_to_accumulate(self):
"""_normalise rejects anything outside accumulate|separate."""
from gateway.display_config import resolve_display_setting
config = {"display": {"tool_progress_grouping": "bogus"}}
assert (
resolve_display_setting(config, "telegram", "tool_progress_grouping")
== "accumulate"
)
def test_case_insensitive(self):
from gateway.display_config import resolve_display_setting
config = {"display": {"tool_progress_grouping": "SEPARATE"}}
assert (
resolve_display_setting(config, "telegram", "tool_progress_grouping")
== "separate"
)
+284
View File
@@ -6,6 +6,124 @@ import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from gateway.config import Platform, PlatformConfig
from gateway.run import (
_resolve_gateway_display_bool,
_resolve_progress_thread_id,
)
class TestMattermostProgressThreadRouting:
def test_top_level_mattermost_progress_uses_event_message_id(self):
assert _resolve_progress_thread_id(
Platform.MATTERMOST,
source_thread_id=None,
event_message_id="top_post_123",
) == "top_post_123"
def test_threaded_mattermost_progress_prefers_existing_thread_root(self):
assert _resolve_progress_thread_id(
Platform.MATTERMOST,
source_thread_id="root_post_123",
event_message_id="reply_post_456",
) == "root_post_123"
def test_telegram_progress_does_not_use_message_id_as_thread_id(self):
assert _resolve_progress_thread_id(
Platform.TELEGRAM,
source_thread_id=None,
event_message_id="12345",
) is None
class TestMattermostDisplayHygiene:
def test_mattermost_requires_platform_opt_in_for_interim_assistant_messages(self):
"""Global interim commentary must not make Mattermost leak scratch notes."""
user_config = {"display": {"interim_assistant_messages": True}}
assert _resolve_gateway_display_bool(
user_config,
"mattermost",
"interim_assistant_messages",
default=True,
platform=Platform.MATTERMOST,
require_platform_override_for={Platform.MATTERMOST},
) is False
def test_mattermost_platform_opt_in_can_enable_interim_assistant_messages(self):
"""Mattermost can still opt into commentary explicitly per platform."""
user_config = {
"display": {
"interim_assistant_messages": False,
"platforms": {
"mattermost": {"interim_assistant_messages": True},
},
}
}
assert _resolve_gateway_display_bool(
user_config,
"mattermost",
"interim_assistant_messages",
default=True,
platform=Platform.MATTERMOST,
require_platform_override_for={Platform.MATTERMOST},
) is True
def test_mattermost_requires_platform_opt_in_for_thinking_progress(self):
"""Global thinking_progress must not surface internal analysis in Mattermost."""
user_config = {"display": {"thinking_progress": True}}
assert _resolve_gateway_display_bool(
user_config,
"mattermost",
"thinking_progress",
default=False,
platform=Platform.MATTERMOST,
require_platform_override_for={Platform.MATTERMOST},
) is False
def test_mattermost_requires_platform_opt_in_for_show_reasoning(self):
"""Global show_reasoning must not prepend scratch reasoning in Mattermost."""
user_config = {"display": {"show_reasoning": True}}
assert _resolve_gateway_display_bool(
user_config,
"mattermost",
"show_reasoning",
default=False,
platform=Platform.MATTERMOST,
require_platform_override_for={Platform.MATTERMOST},
) is False
def test_mattermost_platform_opt_in_can_enable_show_reasoning(self):
user_config = {
"display": {
"show_reasoning": False,
"platforms": {"mattermost": {"show_reasoning": True}},
}
}
assert _resolve_gateway_display_bool(
user_config,
"mattermost",
"show_reasoning",
default=False,
platform=Platform.MATTERMOST,
require_platform_override_for={Platform.MATTERMOST},
) is True
def test_global_thinking_progress_still_applies_to_other_platforms(self):
"""The Mattermost guard must not silently neuter Telegram/other chats."""
user_config = {"display": {"thinking_progress": True}}
assert _resolve_gateway_display_bool(
user_config,
"telegram",
"thinking_progress",
default=False,
platform=Platform.TELEGRAM,
require_platform_override_for={Platform.MATTERMOST},
) is True
# ---------------------------------------------------------------------------
@@ -237,6 +355,110 @@ class TestMattermostSend:
payload = self.adapter._session.post.call_args[1]["json"]
assert "root_id" not in payload
@pytest.mark.asyncio
async def test_send_uses_metadata_thread_id_for_progress_messages(self):
"""Progress/status messages pass Mattermost thread context via metadata."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "root_post_123", "root_id": ""})
self.adapter._api_post = AsyncMock(return_value={"id": "progress_post"})
result = await self.adapter.send(
"channel_1",
"⚡ terminal...",
metadata={"thread_id": "root_post_123"},
)
assert result.success is True
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "root_post_123"
@pytest.mark.asyncio
async def test_progress_send_with_invalid_thread_root_never_falls_back_flat(self):
"""Tool/status/progress bubbles must stay quiet when the thread is broken."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""})
self.adapter._last_post_status = 400
self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id"
self.adapter._api_post = AsyncMock(return_value={})
result = await self.adapter.send(
"channel_1",
"⚙️ terminal...",
metadata={"thread_id": "bad_root"},
)
assert result.success is False
assert self.adapter._api_post.call_count == 1
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "bad_root"
@pytest.mark.asyncio
async def test_notify_send_with_invalid_thread_root_falls_back_flat_with_warning(self):
"""Notify-worthy replies may fall back flat so the answer is not lost."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""})
self.adapter._last_post_status = 400
self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id"
self.adapter._api_post = AsyncMock(side_effect=[{}, {"id": "flat_final"}])
result = await self.adapter.send(
"channel_1",
"Final answer body",
reply_to="bad_root",
metadata={"notify": True},
)
assert result.success is True
assert result.message_id == "flat_final"
assert self.adapter._api_post.call_count == 2
threaded_payload = self.adapter._api_post.call_args_list[0][0][1]
flat_payload = self.adapter._api_post.call_args_list[1][0][1]
assert threaded_payload["root_id"] == "bad_root"
assert "root_id" not in flat_payload
assert flat_payload["channel_id"] == "channel_1"
assert "Mattermost thread delivery failed" in flat_payload["message"]
assert "Final answer body" in flat_payload["message"]
@pytest.mark.asyncio
async def test_notify_send_with_server_error_does_not_fall_back_flat(self):
"""Notify fallback is only for broken thread roots, not generic API failures."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "root_post", "root_id": ""})
self.adapter._last_post_status = 500
self.adapter._last_post_error = "Internal Server Error"
self.adapter._api_post = AsyncMock(return_value={})
result = await self.adapter.send(
"channel_1",
"Final answer body",
reply_to="root_post",
metadata={"notify": True},
)
assert result.success is False
assert self.adapter._api_post.call_count == 1
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "root_post"
@pytest.mark.asyncio
async def test_progress_send_with_invalid_thread_root_never_falls_back_flat(self):
"""Tool/status/progress bubbles must stay quiet when the thread is broken."""
self.adapter._reply_mode = "thread"
self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""})
self.adapter._api_post = AsyncMock(return_value={})
result = await self.adapter.send(
"channel_1",
"⚙️ terminal...",
metadata={"thread_id": "bad_root"},
)
assert result.success is False
assert self.adapter._api_post.call_count == 1
payload = self.adapter._api_post.call_args_list[0][0][1]
assert payload["root_id"] == "bad_root"
@pytest.mark.asyncio
async def test_send_api_failure(self):
"""When API returns error, send should return failure."""
@@ -750,3 +972,65 @@ class TestMattermostMediaTypes:
assert msg.media_types == ["application/pdf"]
assert not msg.media_types[0].startswith("image/")
assert not msg.media_types[0].startswith("audio/")
@pytest.mark.asyncio
async def test_mattermost_top_level_channel_post_is_thread_root():
adapter = _make_adapter()
adapter._reply_mode = "thread"
adapter._bot_user_id = "bot_user_id"
adapter._bot_username = "hermes-bot"
adapter.handle_message = AsyncMock()
post_data = {
"id": "top_post_123",
"user_id": "user_123",
"channel_id": "chan_456",
"message": "@hermes-bot start work",
"root_id": "",
}
event = {
"event": "posted",
"data": {
"post": json.dumps(post_data),
"channel_type": "O",
"sender_name": "@alice",
},
}
await adapter._handle_ws_event(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.source.thread_id == "top_post_123"
assert msg_event.source.message_id == "top_post_123"
assert msg_event.message_id == "top_post_123"
@pytest.mark.asyncio
async def test_mattermost_dm_post_does_not_seed_thread_root():
adapter = _make_adapter()
adapter._reply_mode = "thread"
adapter._bot_user_id = "bot_user_id"
adapter._bot_username = "hermes-bot"
adapter.handle_message = AsyncMock()
post_data = {
"id": "dm_post_123",
"user_id": "user_123",
"channel_id": "dm_chan",
"message": "hello",
"root_id": "",
}
event = {
"event": "posted",
"data": {
"post": json.dumps(post_data),
"channel_type": "D",
"sender_name": "@alice",
},
}
await adapter._handle_ws_event(event)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.source.thread_id is None
assert msg_event.source.message_id == "dm_post_123"
@@ -0,0 +1,80 @@
"""Contract: media-send overrides must accept the ``metadata`` kwarg.
``BasePlatformAdapter.send_multiple_images`` passes ``metadata=metadata``
to ``send_image`` / ``send_image_file`` / ``send_animation`` on every send.
An override whose signature stops at ``reply_to`` raises ``TypeError:
send_image() got an unexpected keyword argument 'metadata'`` at runtime
which is exactly how image delivery broke on WhatsApp and email.
This mirrors ``test_discord_media_metadata.py`` but covers the two
adapters that previously slipped, plus a best-effort sweep over every
adapter that imports cleanly so the next slip is caught at test time.
"""
from __future__ import annotations
import importlib
import inspect
import pytest
def _accepts_metadata(method) -> bool:
params = inspect.signature(method).parameters
if "metadata" in params:
return True
# A ``**kwargs`` catch-all also absorbs metadata (the convention used by
# WhatsApp's send_video / send_voice / send_document overrides).
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
# (module, class) for the two adapters this fix targeted. These must import
# in CI, so assert directly rather than skipping.
@pytest.mark.parametrize(
"module_name, class_name",
[
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
("gateway.platforms.email", "EmailAdapter"),
],
)
def test_send_image_accepts_metadata(module_name, class_name):
cls = getattr(importlib.import_module(module_name), class_name)
assert _accepts_metadata(cls.send_image), (
f"{class_name}.send_image must accept 'metadata' (or **kwargs) — "
f"send_multiple_images passes it on every send"
)
# Best-effort sweep across all shipped adapters. Modules whose optional
# platform SDK isn't installed are skipped; an adapter that imports but
# whose override drops metadata is a hard failure.
_ALL_ADAPTERS = [
("gateway.platforms.bluebubbles", "BlueBubblesAdapter"),
("gateway.platforms.dingtalk", "DingTalkAdapter"),
("gateway.platforms.discord", "DiscordAdapter"),
("gateway.platforms.email", "EmailAdapter"),
("gateway.platforms.feishu", "FeishuAdapter"),
("gateway.platforms.matrix", "MatrixAdapter"),
("gateway.platforms.mattermost", "MattermostAdapter"),
("gateway.platforms.signal", "SignalAdapter"),
("gateway.platforms.slack", "SlackAdapter"),
("gateway.platforms.telegram", "TelegramAdapter"),
("gateway.platforms.wecom", "WeComAdapter"),
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
]
@pytest.mark.parametrize("module_name, class_name", _ALL_ADAPTERS)
def test_all_adapters_send_image_metadata_sweep(module_name, class_name):
try:
module = importlib.import_module(module_name)
except Exception as exc: # optional platform dep not installed
pytest.skip(f"{module_name} not importable: {exc}")
cls = getattr(module, class_name, None)
if cls is None or "send_image" not in cls.__dict__:
pytest.skip(f"{class_name} has no send_image override")
assert _accepts_metadata(cls.send_image), (
f"{class_name}.send_image drops the 'metadata' kwarg"
)
@@ -106,6 +106,42 @@ class TestInitialReplyToId:
assert call_kwargs["metadata"] == {**metadata, "expect_edits": True}
assert metadata == {"thread_id": "omt_topic789"}
@pytest.mark.asyncio
async def test_final_first_send_marks_metadata_notify_true(self):
"""Final streaming sends should use the existing notify=True marker."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter,
"chat_123",
metadata={"thread_id": "root_post_123"},
initial_reply_to_id="reply_post_456",
)
await consumer._send_or_edit("Final answer", finalize=True)
call_kwargs = adapter.send.call_args[1]
metadata = call_kwargs["metadata"]
assert metadata["thread_id"] == "root_post_123"
assert metadata["notify"] is True
assert "delivery_kind" not in metadata
assert "allow_flat_fallback" not in metadata
@pytest.mark.asyncio
async def test_nonfinal_first_send_does_not_mark_notify(self):
"""Preview/interim streaming sends must not be notify-worthy."""
adapter = _make_adapter()
consumer = GatewayStreamConsumer(
adapter,
"chat_123",
metadata={"thread_id": "root_post_123"},
initial_reply_to_id="reply_post_456",
)
await consumer._send_or_edit("Preview", finalize=False)
metadata = adapter.send.call_args[1]["metadata"]
assert metadata == {"thread_id": "root_post_123", "expect_edits": True}
class TestOverflowFirstMessage:
"""Verify thread routing is preserved when the first message overflows."""
@@ -134,7 +134,7 @@ async def test_stream_consumer_fallback_sends_tail_after_partial_overflow():
adapter.send.assert_awaited_once()
assert adapter.send.await_args.kwargs["content"] == "world"
assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77"}
assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77", "notify": True}
adapter.delete_message.assert_not_awaited()
assert consumer.final_response_sent is True
assert consumer.final_content_delivered is True
+184 -3
View File
@@ -61,6 +61,8 @@ def _make_adapter(extra=None):
bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
bot.send_chat_action = AsyncMock() # keeps the post-send typing re-trigger quiet
bot.send_message_draft = AsyncMock(return_value=True) # legacy draft fallback
bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1)) # legacy edit path
bot.delete_message = AsyncMock(return_value=True)
adapter._bot = bot
return adapter
@@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false():
@pytest.mark.asyncio
async def test_rich_messages_default_is_disabled():
async def test_rich_messages_default_is_enabled():
"""Rich messages are on by default (Bot API 10.1); rich-eligible content
(tables/task lists/details/math) goes through sendRichMessage without the
user having to opt in."""
config = PlatformConfig(enabled=True, token="fake-token")
adapter = TelegramAdapter(config)
bot = MagicMock()
@@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled():
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
bot = adapter._bot
assert bot is not None
bot.do_api_request.assert_awaited_once()
bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_rich_messages_can_be_opted_out():
"""Setting platforms.telegram.extra.rich_messages: false keeps every reply
on the legacy MarkdownV2 path even for rich-eligible content."""
config = PlatformConfig(
enabled=True, token="fake-token", extra={"rich_messages": False}
)
adapter = TelegramAdapter(config)
bot = MagicMock()
bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123))
bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
bot.send_chat_action = AsyncMock()
adapter._bot = bot
result = await adapter.send("12345", RICH_CONTENT)
assert result.success is True
bot.do_api_request.assert_not_called()
bot.send_message.assert_awaited()
@pytest.mark.asyncio
async def test_plain_markdown_stays_on_legacy_path():
"""Ordinary replies (no table/task-list/details/math) stay on the legacy
MarkdownV2 path for consistent client rendering, even with rich enabled."""
adapter = _make_adapter()
result = await adapter.send("12345", "Hello **there**\n\nA normal reply.")
assert result.success is True
bot = adapter._bot
assert bot is not None
@@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks():
async def test_rich_limit_is_characters_not_bytes():
"""Telegram's rich limit is UTF-8 characters, not encoded bytes."""
adapter = _make_adapter()
cjk = "" * 20000 # 20k chars, 60k UTF-8 bytes
# Rich-eligible (table) so the content takes the rich path; the CJK body
# is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap.
cjk = "| a | b |\n|---|---|\n" + "" * 20000 # 20k chars, ~60k UTF-8 bytes
assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
@@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
async def test_rich_payload_preserves_link_preview_disable():
adapter = _make_adapter(extra={"disable_link_previews": True})
result = await adapter.send("12345", "See https://example.com")
result = await adapter.send(
"12345", "| Link | Note |\n|---|---|\n| See https://example.com | x |"
)
assert result.success is True
api_kwargs = _rich_api_kwargs(adapter)
@@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy():
assert bot is not None
bot.do_api_request.assert_not_called()
bot.send_message_draft.assert_awaited_once()
# ----------------------------------------------------------------------------
# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param).
# Streamed previews finalize by editing the existing message IN PLACE as rich,
# so tables/task lists survive without a fresh send + delete (no duplicate).
# ----------------------------------------------------------------------------
def _rich_edit_kwargs(adapter):
"""Return the api_kwargs dict from the single editMessageText rich call."""
call = adapter._bot.do_api_request.call_args
assert call.args[0] == "editMessageText"
return call.kwargs["api_kwargs"]
@pytest.mark.asyncio
async def test_finalize_edit_uses_rich_for_table_content():
"""Finalizing a streamed preview whose content is a table edits the
existing message IN PLACE via editMessageText's rich_message param —
no fresh send, no delete, no duplicate."""
adapter = _make_adapter()
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
assert result.message_id == "555" # same message, edited in place
api_kwargs = _rich_edit_kwargs(adapter)
assert api_kwargs["message_id"] == 555
# RAW markdown is passed through so table pipes survive.
assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT
# No fresh send / delete — the whole point of the in-place rich edit.
adapter._bot.edit_message_text.assert_not_called()
adapter._bot.delete_message.assert_not_called()
@pytest.mark.asyncio
async def test_finalize_edit_plain_content_stays_legacy():
"""Finalizing plain content (no table/task-list/details/math) uses the
legacy MarkdownV2 edit_message_text path, not the rich edit endpoint."""
adapter = _make_adapter()
result = await adapter.edit_message(
"12345", "555", "Just a normal answer, no rich constructs.", finalize=True,
)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_rich_capability_error_falls_back_to_legacy():
"""A capability error on the rich edit latches rich off and falls back to
the legacy MarkdownV2 edit so the user still gets the final answer."""
adapter = _make_adapter()
adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND)
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
assert adapter._rich_send_disabled is True
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_rich_not_modified_is_success_noop():
"""'Message is not modified' on a rich edit is a no-op success — must NOT
fall through to a redundant legacy edit."""
adapter = _make_adapter()
adapter._bot.do_api_request = AsyncMock(
side_effect=BadRequest("Message is not modified")
)
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
adapter._bot.edit_message_text.assert_not_called()
@pytest.mark.asyncio
async def test_non_finalize_edit_never_uses_rich():
"""Intermediate (non-finalize) stream edits stay on the plain edit path;
rich is only applied on the final edit."""
adapter = _make_adapter()
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=False,
)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_opt_out_uses_legacy():
"""With rich_messages: false, even a table finalizes via the legacy
MarkdownV2 edit path."""
adapter = _make_adapter(extra={"rich_messages": False})
result = await adapter.edit_message(
"12345", "555", RICH_CONTENT, finalize=True,
)
assert result.success is True
adapter._bot.do_api_request.assert_not_called()
adapter._bot.edit_message_text.assert_awaited()
@pytest.mark.asyncio
async def test_finalize_edit_rich_over_markdownv2_limit_not_split():
"""A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768
rich cap is edited in place as one rich message, NOT split into legacy
chunks."""
adapter = _make_adapter()
big_table = "| a | b |\n|---|---|\n" + "\n".join(
f"| {'x' * 50} | {'y' * 50} |" for _ in range(40)
)
assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH
assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
result = await adapter.edit_message(
"12345", "555", big_table, finalize=True,
)
assert result.success is True
api_kwargs = _rich_edit_kwargs(adapter)
assert api_kwargs["rich_message"]["markdown"] == big_table
adapter._bot.edit_message_text.assert_not_called()
+3 -3
View File
@@ -76,7 +76,7 @@ async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(tm
adapter.send_document.assert_awaited_once_with(
chat_id="chat-1",
file_path=str(media_file),
metadata=None,
metadata={"notify": True},
)
adapter.send_voice.assert_not_awaited()
@@ -95,7 +95,7 @@ async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_
adapter.send_document.assert_awaited_once_with(
chat_id="chat-1",
file_path=str(media_file),
metadata=None,
metadata={"notify": True},
)
adapter.send_voice.assert_not_awaited()
@@ -116,7 +116,7 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_
adapter.send_voice.assert_awaited_once_with(
chat_id="chat-1",
audio_path=str(media_file),
metadata=None,
metadata={"notify": True},
)
adapter.send_document.assert_not_awaited()
+70
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import base64
import json
import time
from datetime import datetime, timezone
from unittest.mock import patch
@@ -25,6 +26,37 @@ def _jwt_with_email(email: str) -> str:
return f"{header}.{payload}.signature"
def _codex_pool_only_store(*, exhausted: bool = False) -> dict:
entry = {
"id": "codex-1",
"label": "codex@example.com",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": _jwt_with_email("codex@example.com"),
"refresh_token": "refresh-token",
"base_url": "https://chatgpt.com/backend-api/codex",
"last_refresh": "2026-06-15T10:00:00Z",
}
if exhausted:
entry.update(
{
"last_status": "exhausted",
"last_status_at": time.time(),
"last_error_code": 429,
"last_error_reason": "usage_limit_reached",
"last_error_message": "The usage limit has been reached",
"last_error_reset_at": time.time() + 3600,
}
)
return {
"version": 1,
"active_provider": "openai-codex",
"providers": {},
"credential_pool": {"openai-codex": [entry]},
}
@pytest.fixture(autouse=True)
def _clear_provider_env(monkeypatch):
for key in (
@@ -483,6 +515,44 @@ def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch
assert payload["active_provider"] == "openai-codex"
def test_codex_auth_status_reports_pool_only_credential(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, _codex_pool_only_store())
from hermes_cli.auth import get_codex_auth_status
status = get_codex_auth_status()
assert status["logged_in"] is True
assert status["source"] == "pool:codex@example.com"
def test_codex_auth_status_reports_pool_only_rate_limit(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, _codex_pool_only_store(exhausted=True))
from hermes_cli.auth import get_codex_auth_status
status = get_codex_auth_status()
assert status["logged_in"] is True
assert status["rate_limited"] is True
assert status["error_code"] == "codex_rate_limited"
def test_codex_runtime_pool_only_rate_limit_is_not_missing_auth(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, _codex_pool_only_store(exhausted=True))
from hermes_cli.auth import AuthError, CODEX_RATE_LIMITED_CODE, resolve_codex_runtime_credentials
with pytest.raises(AuthError) as exc_info:
resolve_codex_runtime_credentials()
assert exc_info.value.code == CODEX_RATE_LIMITED_CODE
assert exc_info.value.relogin_required is False
def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
"""hermes auth add xai-oauth must write providers singleton and set active_provider.
@@ -772,6 +772,25 @@ class TestUpdateCheckEndpoint:
assert body["message"]
assert body["behind"] is None
def test_managed_runtime_dashboard_is_not_applyable(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_dashboard_local_update_managed_externally", lambda: True)
monkeypatch.setattr(
ws,
"detect_install_method",
lambda *a, **k: pytest.fail(
"managed runtime update check should not probe install method"
),
)
body = self.client.get("/api/hermes/update/check").json()
assert body["install_method"] == "managed-runtime"
assert body["can_apply"] is False
assert body["update_available"] is False
assert body["behind"] is None
assert "managed outside this dashboard" in body["message"]
def test_check_failure_is_soft(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
@@ -3,8 +3,9 @@
The cache avoids re-validating Nous credentials on every menu paint
`hermes tools` "All Platforms" used to fire ~31 OAuth refresh POSTs
against portal.nousresearch.com during one render. The cache is keyed
on auth.json mtime so login/logout flows invalidate naturally; tests
and other writers can also call invalidate_nous_auth_status_cache().
on auth.json path + mtime so profile switches stay isolated while
login/logout flows invalidate naturally; tests and other writers can
also call invalidate_nous_auth_status_cache().
"""
from __future__ import annotations
@@ -88,6 +89,42 @@ def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypat
auth_mod.invalidate_nous_auth_status_cache()
def test_get_nous_auth_status_cache_is_scoped_by_auth_file_path(tmp_path, monkeypatch):
"""Two profile homes with missing auth.json must not share cached status."""
profile_a = tmp_path / "profiles" / "a"
profile_b = tmp_path / "profiles" / "b"
profile_a.mkdir(parents=True)
profile_b.mkdir(parents=True)
from hermes_cli import auth as auth_mod
auth_mod.invalidate_nous_auth_status_cache()
call_count = {"n": 0}
seen_auth_files = []
def fake_compute():
call_count["n"] += 1
seen_auth_files.append(auth_mod._auth_file_path())
return {"logged_in": False, "call": call_count["n"]}
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
monkeypatch.setenv("HERMES_HOME", str(profile_a))
first = auth_mod.get_nous_auth_status()
monkeypatch.setenv("HERMES_HOME", str(profile_b))
second = auth_mod.get_nous_auth_status()
assert call_count["n"] == 2
assert first["call"] == 1
assert second["call"] == 2
assert seen_auth_files == [
profile_a / "auth.json",
profile_b / "auth.json",
]
auth_mod.invalidate_nous_auth_status_cache()
def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch):
"""Explicit invalidate forces the next call to re-compute."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+133
View File
@@ -34,6 +34,13 @@ client = TestClient(app)
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profile_home = tmp_path / "profiles" / profile
profile_home.mkdir(parents=True)
return profile_home
def _fake_nous_device_data():
return {
"device_code": "device-code",
@@ -127,6 +134,67 @@ def test_nous_dashboard_device_flow_ignores_legacy_scope_override(monkeypatch):
ws._oauth_sessions.pop(result["session_id"], None)
def test_oauth_provider_status_uses_profile_query(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
from hermes_constants import get_hermes_home
profile_home = _make_profile_home(tmp_path, monkeypatch)
observed_homes = []
def fake_status():
observed_homes.append(get_hermes_home())
return {"logged_in": False, "source": None}
fake_catalog = ({
"id": "fake-oauth",
"name": "Fake OAuth",
"flow": "pkce",
"cli_command": "hermes auth add fake-oauth",
"docs_url": "https://example.com",
"status_fn": fake_status,
},)
monkeypatch.setattr(ws, "_OAUTH_PROVIDER_CATALOG", fake_catalog)
resp = client.get("/api/providers/oauth?profile=coder", headers=HEADERS)
assert resp.status_code == 200, resp.text
assert observed_homes == [profile_home]
def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypatch):
from hermes_cli import web_server as ws
_make_profile_home(tmp_path, monkeypatch)
fake_user_code_resp = {
"user_code": "ABCD-1234",
"verification_uri": "https://api.minimax.io/oauth/verify",
"expired_in": 600,
"interval": 2000,
"state": "stub-state",
}
with patch(
"hermes_cli.auth._minimax_request_user_code",
return_value=fake_user_code_resp,
), patch(
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
return_value=None,
):
resp = client.post(
"/api/providers/oauth/minimax-oauth/start?profile=coder",
headers=HEADERS,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
try:
assert ws._oauth_sessions[session_id]["profile"] == "coder"
finally:
ws._oauth_sessions.pop(session_id, None)
def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusal(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
@@ -207,6 +275,71 @@ def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch)
ws._oauth_sessions.pop(sid, None)
def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
from hermes_constants import get_hermes_home
profile_home = _make_profile_home(tmp_path, monkeypatch)
class _Resp:
def __init__(self, status_code, payload):
self.status_code = status_code
self._payload = payload
def json(self):
return self._payload
class _Client:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, url, **kwargs):
if url.endswith("/deviceauth/usercode"):
return _Resp(200, {
"device_auth_id": "device-auth-id",
"interval": 3,
"user_code": "CODEX-1234",
})
if url.endswith("/deviceauth/token"):
return _Resp(200, {
"authorization_code": "authorization-code",
"code_verifier": "code-verifier",
})
return _Resp(200, {
"access_token": "codex-access",
"refresh_token": "codex-refresh",
})
saved_homes = []
monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
monkeypatch.setattr(
auth_mod,
"_save_codex_tokens",
lambda tokens: saved_homes.append(get_hermes_home()),
)
sid, _ = ws._new_oauth_session(
"openai-codex",
"device_code",
profile="coder",
)
try:
ws._codex_full_login_worker(sid)
assert ws._oauth_sessions[sid]["status"] == "approved"
assert saved_homes == [profile_home]
finally:
ws._oauth_sessions.pop(sid, None)
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
+60
View File
@@ -245,6 +245,24 @@ class TestWebServerEndpoints:
assert "version" in data
assert "hermes_home" in data
assert "active_sessions" in data
assert data["can_update_hermes"] is True
def test_get_status_hides_update_capability_in_managed_runtime(self, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True)
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["can_update_hermes"] is False
def test_dashboard_update_capability_detects_generic_container(self, monkeypatch):
import hermes_constants
import hermes_cli.web_server as web_server
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
assert web_server._dashboard_local_update_managed_externally() is True
# ── GET /api/media (remote image display) ───────────────────────────
@@ -912,6 +930,48 @@ class TestWebServerEndpoints:
assert status_data["pid"] is None
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
def test_update_hermes_returns_managed_runtime_guidance_without_spawning(self, monkeypatch):
import hermes_cli.web_server as web_server
spawned = False
detected = False
def fail_spawn(*_args, **_kwargs):
nonlocal spawned
spawned = True
raise AssertionError("managed runtime update guard should not spawn hermes update")
def fail_detect(*_args, **_kwargs):
nonlocal detected
detected = True
raise AssertionError("managed runtime update guard should not detect install method")
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True)
monkeypatch.setattr(web_server, "detect_install_method", fail_detect)
monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["name"] == "hermes-update"
assert data["pid"] is None
assert data["error"] == "dashboard_update_managed_externally"
assert "managed outside this dashboard" in data["message"]
assert spawned is False
assert detected is False
status = self.client.get("/api/actions/hermes-update/status")
assert status.status_code == 200
status_data = status.json()
assert status_data["running"] is False
assert status_data["exit_code"] == 1
assert status_data["pid"] is None
assert any("managed outside this dashboard" in line for line in status_data["lines"])
def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch):
import hermes_cli.web_server as web_server
+60
View File
@@ -254,6 +254,66 @@ def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
assert not folder.exists()
def _seed_file(client, root, name="out/hello.txt"):
file_path = root / name
created = client.post(
"/api/files/upload",
json={"path": str(file_path), "data_url": "data:text/plain;base64,aGVsbG8="},
)
assert created.status_code == 200
return file_path
def test_download_returns_file_as_attachment(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
resp = client.get("/api/files/download", params={"path": str(file_path)})
assert resp.status_code == 200
assert resp.content == b"hello"
disposition = resp.headers["content-disposition"]
assert "attachment" in disposition
assert "hello.txt" in disposition
def test_download_authenticates_via_query_token(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
# Drop the session header so only the ?token= query param authenticates —
# mirrors a browser/shell-opened download that can't set the session header.
del client.headers[web_server._SESSION_HEADER_NAME]
ok = client.get(
"/api/files/download",
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
)
assert ok.status_code == 200
assert ok.content == b"hello"
assert client.get(
"/api/files/download", params={"path": str(file_path), "token": "nope"}
).status_code == 401
assert client.get(
"/api/files/download", params={"path": str(file_path)}
).status_code == 401
def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
del client.headers[web_server._SESSION_HEADER_NAME]
# The query-token escape hatch is scoped to /api/files/download only; it must
# not unlock the rest of the API surface.
leaked = client.get(
"/api/files/read",
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
)
assert leaked.status_code == 401
def test_hosted_policy_locks_to_opt_data(monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
monkeypatch.setenv("HERMES_HOME", "/opt/data")
+153 -78
View File
@@ -239,7 +239,7 @@ class TestCloneHonchoForProfile:
"""Identity-key carryover during profile cloning.
The host-scoped identity-mapping keys (``userPeerAliases``,
``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise
``runtimePeerPrefix``, ``pinUserPeer``) must survive a clone; otherwise
the new profile silently fragments memory by resolving gateway users to
raw runtime IDs instead of operator-declared peers.
"""
@@ -263,7 +263,7 @@ class TestCloneHonchoForProfile:
"apiKey": "***",
"hosts": {
"hermes": {
"userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"},
"userPeerAliases": {"7654321": "eri", "discord-491827364": "eri"},
"peerName": "eri",
},
},
@@ -272,7 +272,7 @@ class TestCloneHonchoForProfile:
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"}
assert new_block["userPeerAliases"] == {"7654321": "eri", "discord-491827364": "eri"}
def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
@@ -290,7 +290,7 @@ class TestCloneHonchoForProfile:
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["runtimePeerPrefix"] == "telegram_"
def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path):
def test_legacy_pin_peer_name_migrates_to_canonical_on_clone(self, monkeypatch, tmp_path):
cfg = {
"apiKey": "***",
"hosts": {
@@ -304,7 +304,8 @@ class TestCloneHonchoForProfile:
ok = honcho_cli.clone_honcho_for_profile("coder")
assert ok is True
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert new_block["pinPeerName"] is True
assert new_block["pinUserPeer"] is True
assert "pinPeerName" not in new_block
def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path):
cfg = {
@@ -317,23 +318,25 @@ class TestCloneHonchoForProfile:
new_block = written["cfg"]["hosts"]["hermes_coder"]
assert "userPeerAliases" not in new_block
assert "runtimePeerPrefix" not in new_block
assert "pinUserPeer" not in new_block
assert "pinPeerName" not in new_block
class TestSetupWizardDeploymentShape:
"""The deployment-shape step writes pinPeerName / userPeerAliases /
runtimePeerPrefix based on the operator's chosen shape.
"""The gateway identity-mapping tree writes pinUserPeer / userPeerAliases /
runtimePeerPrefix based on the operator's intent.
Single-operator deployments collapse all platforms to peerName.
Multi-user gateways leave the resolver to route per-runtime.
Hybrid deployments alias the operator's own runtime IDs only.
Choice [1] (just me) collapses all platforms to peerName.
Choice [3] (only other people) leaves the resolver to route per-runtime.
Choice [2] (me + others, pooled) aliases the operator's own runtime IDs.
These tests script the interactive _prompt calls and assert the
resulting hermes_host block, so the wizard's deployment-shape
These tests mock gateway detection and script the interactive _prompt
calls, asserting the resulting hermes_host block so the tree's routing
semantics stay locked even as adjacent prompts are added.
"""
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None,
gateway_platforms=("telegram",)):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "config.json"
@@ -346,6 +349,10 @@ class TestSetupWizardDeploymentShape:
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None)
# Gate detection is mocked so tests control whether the tree runs.
# None → undetectable; list (possibly empty) → connected platforms.
gw = None if gateway_platforms is None else list(gateway_platforms)
monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw)
# Bypass config.yaml + connection test side effects.
monkeypatch.setattr(
@@ -391,14 +398,14 @@ class TestSetupWizardDeploymentShape:
honcho_cli.cmd_setup(SimpleNamespace())
return cfg["hosts"]["hermes"]
def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):
def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"single", # deployment shape ← key answer
"1", # tree: just me ← key answer
# remaining prompts fall through to defaults
]
initial_cfg = {
@@ -409,51 +416,54 @@ class TestSetupWizardDeploymentShape:
}},
}
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["pinUserPeer"] is True
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape
"3", # tree: only other people
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
# Multi must NOT auto-write ``userPeerAliases: {}``: an empty host
# map would silently override a root-level baseline. Absence is
# the correct "no host opinion" signal.
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"hybrid", # deployment shape
"86701400", # telegram uid
"2", # tree: me + other people
"y", # keep my memory pooled? → hybrid
"7654321", # telegram uid
"491827364", # discord snowflake
"", # slack (skip)
"", # matrix (skip)
"", # runtime peer prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {
"86701400": "eri",
"7654321": "eri",
"491827364": "eri",
}
assert "runtimePeerPrefix" not in host
def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path):
# Seeds the legacy ``pinPeerName``: skip must leave the mapping intact
# except for the on-load migration onto the canonical key.
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {
@@ -463,17 +473,18 @@ class TestSetupWizardDeploymentShape:
}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes", "skip",
"cloud", "", "eri", "hermetika", "hermes", "s",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["pinUserPeer"] is True
assert "pinPeerName" not in host
assert host["userPeerAliases"] == {"keep": "me"}
assert host["runtimePeerPrefix"] == "keep_"
def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):
"""Flipping single → multi triggers a warning that auto-steers the
operator to ``hybrid`` (default), so their own runtime IDs keep
landing on peerName instead of orphaning the pinned-pool history.
def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path):
"""Choosing 'only other people' on a currently-pinned profile triggers
the orphan warning, which auto-steers to pooled (hybrid) so the
operator's own runtime IDs keep landing on peerName.
"""
initial_cfg = {
"apiKey": "***",
@@ -485,60 +496,57 @@ class TestSetupWizardDeploymentShape:
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape — triggers the guard
"hybrid", # guard response: accept the steer
"86701400", # telegram uid
"3", # tree: only others — triggers the orphan guard
"y", # pool my own memory instead? → hybrid
"7654321", # telegram uid
"", # discord (skip)
"", # slack (skip)
"", # matrix (skip)
"", # runtime prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {"86701400": "eri"}
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"7654321": "eri"}
def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):
"""Operator can override the steer by answering ``yes`` and accept
the orphaning consequences. This is the explicit undo-the-pin path.
"""
def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path):
"""Operator can decline the steer ('n') and accept orphaning, ending
up with per-user peers (no aliases)."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # deployment shape — triggers the guard
"yes", # guard response: confirm multi
"3", # tree: only others — triggers the orphan guard
"n", # decline pooling, accept orphaning
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
# See test_multi_shape_leaves_pin_false_and_accepts_prefix.
assert host["pinUserPeer"] is False
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path):
"""Host-level ``pinUserPeer: true`` must classify as ``single``.
Pressing Enter at the shape prompt then preserves the pin instead
of falling through to ``multi`` and orphaning the user's memory
pool the bug the wizard regressed when ``pinUserPeer`` landed
as a higher-precedence alias.
Pressing Enter at the choice prompt then preserves the pin instead
of falling through to per-user routing and orphaning the user's
memory pool the bug the wizard regressed when ``pinUserPeer``
landed as a higher-precedence alias.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}},
}
# Exhaust the iterator before the shape prompt so the scripted
# mock falls through to the prompt's default (which is the
# wizard-detected shape). Scripting an explicit "" would NOT
# exercise that fallthrough — the mock returns it literally.
# Exhaust the iterator before the choice prompt so the scripted
# mock falls through to the prompt's default (the detected shape →
# choice "1"). Scripting an explicit "" would NOT exercise that
# fallthrough — the mock returns it literally.
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
# Scrub-then-write normalises onto pinPeerName and drops the alias
# so resolver precedence can't reintroduce ambiguity.
assert host["pinPeerName"] is True
assert "pinUserPeer" not in host
# Scrub-then-write normalises onto the canonical pinUserPeer.
assert host["pinUserPeer"] is True
assert "pinPeerName" not in host
def test_host_pin_user_peer_false_overrides_root_pin_peer_name(
self, monkeypatch, tmp_path
@@ -558,8 +566,8 @@ class TestSetupWizardDeploymentShape:
}
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert "pinUserPeer" not in host
assert host["pinUserPeer"] is False
assert "pinPeerName" not in host
def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path):
"""Root-level ``userPeerAliases`` must classify as ``hybrid`` even
@@ -567,26 +575,26 @@ class TestSetupWizardDeploymentShape:
"""
initial_cfg = {
"apiKey": "***",
"userPeerAliases": {"86701400": "eri"},
"userPeerAliases": {"7654321": "eri"},
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
# Hybrid materialises the root aliases into the host so subsequent
# operator edits live on the host block they're inspecting.
assert host["userPeerAliases"] == {"86701400": "eri"}
assert host["userPeerAliases"] == {"7654321": "eri"}
def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicit ``multi`` must leave the host ``userPeerAliases`` key
absent, preserving any root-level aliases as a cross-host baseline.
def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicitly choosing 'only other people' must leave the host
``userPeerAliases`` key absent, preserving any root-level aliases as a
cross-host baseline.
Picking ``multi`` here is an active choice detection would have
defaulted to ``hybrid`` because root aliases exist so the
operator's intent is to drop the alias mapping for this host.
We honor that by writing ``pinPeerName: false`` only, and rely
on the host's absence of ``userPeerAliases`` to inherit root.
That inheritance is intentional: a true wipe would require the
Picking [3] here is an active choice detection would have defaulted
to [2]/hybrid because root aliases exist so the operator's intent is
to drop the alias mapping for this host. We honor that by writing
``pinUserPeer: false`` only, relying on the host's absence of
``userPeerAliases`` to inherit root. A true wipe would require the
operator to delete the root key explicitly.
"""
initial_cfg = {
@@ -596,17 +604,15 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # explicit multi override of detected hybrid
"3", # explicit per-user override of detected hybrid
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["pinUserPeer"] is False
assert "userPeerAliases" not in host
def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing ``single`` must drop any host-level ``pinUserPeer``,
otherwise an existing ``pinUserPeer: false`` would outrank the
freshly written ``pinPeerName: true`` and leave the profile
effectively unpinned (the P1 latent-precedence regression).
def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing 'just me' must overwrite a stale ``pinUserPeer: false``
with ``pinUserPeer: true`` so the profile ends up genuinely pinned.
"""
initial_cfg = {
"apiKey": "***",
@@ -617,11 +623,56 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"single",
"1",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["pinUserPeer"] is True
def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""With no gateway platforms connected, the tree is gated off; declining
the 'configure anyway?' prompt leaves identity mapping untouched."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=[],
)
assert "pinUserPeer" not in host
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""When the gateway package can't be inspected (None), the wizard asks
whether the gateway is running; 'no' skips the mapping step."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=None,
)
assert "pinUserPeer" not in host
def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path):
"""The [e] escape hatch lets a power user set pinUserPeer + an alias +
prefix directly, bypassing the intent tree."""
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"e", # tree: edit raw keys
"false", # pinUserPeer
"99887766=eri", # one alias pair
"", # finish aliases
"discord_", # runtimePeerPrefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"99887766": "eri"}
assert host["runtimePeerPrefix"] == "discord_"
class TestCloneCarriesPinUserPeer:
@@ -653,3 +704,27 @@ class TestCloneCarriesPinUserPeer:
assert ok is True
new_block = written["cfg"]["hosts"]["hermes_partner"]
assert new_block["pinUserPeer"] is True
class TestMigratePinKey:
"""``_migrate_pin_key`` rewrites the legacy ``pinPeerName`` onto the
canonical ``pinUserPeer`` in place, without clobbering an existing
canonical value."""
def test_legacy_key_renamed_to_canonical(self):
import plugins.memory.honcho.cli as honcho_cli
block = {"pinPeerName": True}
assert honcho_cli._migrate_pin_key(block) is True
assert block == {"pinUserPeer": True}
def test_canonical_key_wins_when_both_present(self):
import plugins.memory.honcho.cli as honcho_cli
block = {"pinPeerName": True, "pinUserPeer": False}
assert honcho_cli._migrate_pin_key(block) is True
assert block == {"pinUserPeer": False}
def test_noop_when_no_legacy_key(self):
import plugins.memory.honcho.cli as honcho_cli
block = {"pinUserPeer": True}
assert honcho_cli._migrate_pin_key(block) is False
assert block == {"pinUserPeer": True}
+60 -60
View File
@@ -105,7 +105,7 @@ class TestRuntimePeerMappingConfigParsing:
config_file.write_text(json.dumps({
"apiKey": "k",
"userPeerAliases": {
" 86701400 ": " Igor ",
" 7654321 ": " Igor ",
"": "ignored",
"empty-value": " ",
"null-value": None,
@@ -115,7 +115,7 @@ class TestRuntimePeerMappingConfigParsing:
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.user_peer_aliases == {"86701400": "Igor"}
assert config.user_peer_aliases == {"7654321": "Igor"}
assert config.runtime_peer_prefix == "telegram_"
def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path):
@@ -226,12 +226,12 @@ class TestPeerResolutionOrder:
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(peer_name="Igor", pin_peer_name=False),
runtime_user_peer_name="86701400", # e.g. Telegram UID
runtime_user_peer_name="7654321", # e.g. Telegram UID
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "86701400", (
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "7654321", (
"pin_peer_name=False is the multi-user default — the gateway's "
"platform-native user ID must win so each user gets their own "
"peer scope. If this regresses, every Telegram/Discord/Slack "
@@ -245,14 +245,14 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name="Igor",
pin_peer_name=False,
user_peer_aliases={"86701400": "Igor"},
user_peer_aliases={"7654321": "Igor"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor"
def test_unknown_runtime_id_uses_prefix(self):
@@ -264,12 +264,12 @@ class TestPeerResolutionOrder:
pin_peer_name=False,
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "telegram_86701400"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "telegram_7654321"
def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self):
"""Generated prefixed IDs avoid merges caused by lossy sanitization."""
@@ -291,43 +291,43 @@ class TestPeerResolutionOrder:
def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self):
"""Unknown generated peers should not silently merge into peerName."""
raw_peer_id = "telegram_86701400"
raw_peer_id = "telegram_7654321"
expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8]
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(
peer_name="telegram_86701400",
peer_name="telegram_7654321",
pin_peer_name=False,
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == f"telegram_86701400-{expected_hash}"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == f"telegram_7654321-{expected_hash}"
def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self):
"""Unknown generated peers should not silently merge into alias targets."""
raw_peer_id = "telegram_86701400"
raw_peer_id = "telegram_7654321"
expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8]
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(
peer_name=None,
pin_peer_name=False,
user_peer_aliases={"known-user": "telegram_86701400"},
user_peer_aliases={"known-user": "telegram_7654321"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == f"telegram_86701400-{expected_hash}"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == f"telegram_7654321-{expected_hash}"
def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self):
raw_peer_id = "telegram_86701400"
raw_peer_id = "telegram_7654321"
digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()
mgr = HonchoSessionManager(
honcho=MagicMock(),
@@ -335,17 +335,17 @@ class TestPeerResolutionOrder:
peer_name=None,
pin_peer_name=False,
user_peer_aliases={
"known-user": "telegram_86701400",
"reserved-user": f"telegram_86701400-{digest[:8]}",
"known-user": "telegram_7654321",
"reserved-user": f"telegram_7654321-{digest[:8]}",
},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == f"telegram_86701400-{digest[:12]}"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == f"telegram_7654321-{digest[:12]}"
def test_alias_value_is_sanitized_after_selection(self):
mgr = HonchoSessionManager(
@@ -353,13 +353,13 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name=None,
pin_peer_name=False,
user_peer_aliases={"86701400": "Alice Smith!"},
user_peer_aliases={"7654321": "Alice Smith!"},
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Alice-Smith-"
def test_alias_keys_match_raw_runtime_id_before_sanitization(self):
@@ -391,13 +391,13 @@ class TestPeerResolutionOrder:
runtime_peer_prefix="telegram_",
session_peer_prefix=True,
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "telegram_86701400"
assert session.honcho_session_id == "telegram-86701400"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "telegram_7654321"
assert session.honcho_session_id == "telegram-7654321"
def test_config_wins_when_pin_is_true(self):
"""With pin enabled, configured peer_name beats runtime ID."""
@@ -406,14 +406,14 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name="Igor",
pin_peer_name=True,
user_peer_aliases={"86701400": "Alias"},
user_peer_aliases={"7654321": "Alias"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400", # Telegram pushes this in
runtime_user_peer_name="7654321", # Telegram pushes this in
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor", (
"With pinPeerName=true the user's configured peer_name must "
"beat the platform-native runtime ID so memory stays unified "
@@ -429,26 +429,26 @@ class TestPeerResolutionOrder:
config=self._config(
peer_name=None,
pin_peer_name=True,
user_peer_aliases={"86701400": "Igor"},
user_peer_aliases={"7654321": "Igor"},
runtime_peer_prefix="telegram_",
),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor"
def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self):
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._config(peer_name=None, pin_peer_name=True),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
assert session.user_peer_id == "86701400"
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "7654321"
def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self):
"""Stable alternate IDs can map known users while primary ID fallback stays unchanged."""
@@ -526,11 +526,11 @@ class TestPeerResolutionOrder:
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=cfg,
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
session = mgr.get_or_create("telegram:86701400")
session = mgr.get_or_create("telegram:7654321")
assert session.user_peer_id == "Igor"
assert session.assistant_peer_id == "hermes-assistant"
@@ -556,10 +556,10 @@ class TestCrossPlatformMemoryUnification:
mgr_telegram = HonchoSessionManager(
honcho=MagicMock(),
config=self._config_pinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr_telegram)
telegram_session = mgr_telegram.get_or_create("telegram:86701400")
telegram_session = mgr_telegram.get_or_create("telegram:7654321")
# Discord turn (separate manager instance — simulates a fresh
# platform-adapter invocation)
@@ -701,20 +701,20 @@ class TestPinTransition:
pinned_mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(pinned_mgr)
before = pinned_mgr.get_or_create("telegram:86701400")
before = pinned_mgr.get_or_create("telegram:7654321")
assert before.user_peer_id == "Igor"
unpinned_mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._unpinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(unpinned_mgr)
after = unpinned_mgr.get_or_create("telegram:86701400")
assert after.user_peer_id == "86701400", (
after = unpinned_mgr.get_or_create("telegram:7654321")
assert after.user_peer_id == "7654321", (
"After flipping pinPeerName off, the same runtime ID must resolve "
"to its own peer — otherwise multi-user mode silently merges users."
)
@@ -723,14 +723,14 @@ class TestPinTransition:
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned(),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr)
first = mgr.get_or_create("telegram:86701400")
first = mgr.get_or_create("telegram:7654321")
assert first.user_peer_id == "Igor"
mgr._config = self._unpinned()
second = mgr.get_or_create("telegram:86701400")
second = mgr.get_or_create("telegram:7654321")
assert second.user_peer_id == "Igor", (
"The per-key session cache is keyed by session-key, not by "
"resolved peer. In-process flips don't invalidate it — the "
@@ -764,7 +764,7 @@ class TestPinTransition:
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"userPeerAliases": {"86701400": "Igor"},
"userPeerAliases": {"7654321": "Igor"},
}))
sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}})
@@ -839,18 +839,18 @@ class TestProfilePeerUniqueness:
mgr_a = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned_to("alice"),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr_a)
sess_a = mgr_a.get_or_create("telegram:86701400")
sess_a = mgr_a.get_or_create("telegram:7654321")
mgr_b = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned_to("bob"),
runtime_user_peer_name="86701400",
runtime_user_peer_name="7654321",
)
_patch_manager_for_resolution_test(mgr_b)
sess_b = mgr_b.get_or_create("telegram:86701400")
sess_b = mgr_b.get_or_create("telegram:7654321")
assert sess_a.user_peer_id == "alice"
assert sess_b.user_peer_id == "bob"
+10 -45
View File
@@ -88,7 +88,10 @@ def test_store_project_credentials_round_trip(
sid, secret = photon_auth.load_project_credentials()
assert sid == "sp-123"
assert secret == "secret-key"
assert photon_auth.load_dashboard_project_id() == "dash-456"
# Post-unification the management id resolves to the Spectrum id, not the
# stored dashboard id — so a pre-backfill diverged install (whose old
# dashboard id was rewritten and now 404s) still reaches the live row.
assert photon_auth.load_dashboard_project_id() == "sp-123"
def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
@@ -284,7 +287,7 @@ def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch)
assert proj is not None and proj["id"] == "p2"
def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None:
captured: Dict[str, Any] = {}
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
@@ -296,7 +299,9 @@ def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) ->
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
data = photon_auth.create_project("tok", name="Hermes Agent")
assert data["id"] == "new-proj"
assert captured["body"]["spectrum"] is True
# Spectrum is always provisioned at create-time; the field was dropped
# from the API schema, so we must not send it.
assert "spectrum" not in captured["body"]
assert captured["body"]["name"] == "Hermes Agent"
assert captured["headers"]["Authorization"] == "Bearer tok"
assert captured["url"].endswith("/api/projects")
@@ -311,46 +316,6 @@ def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> No
photon_auth.create_project("tok")
def test_ensure_spectrum_enabled_toggles_when_off(monkeypatch: pytest.MonkeyPatch) -> None:
get_calls = {"n": 0}
posted = {"toggle": False}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
get_calls["n"] += 1
if get_calls["n"] == 1:
return _FakeResponse(json_body={"id": "p", "spectrum": False, "spectrumProjectId": None})
return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
if url.endswith("/spectrum/toggle"):
posted["toggle"] = True
return _FakeResponse(json_body={"success": True})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
proj = photon_auth.ensure_spectrum_enabled("tok", "p")
assert posted["toggle"] is True
assert proj["spectrumProjectId"] == "sp-1"
def test_ensure_spectrum_enabled_skips_toggle_when_on(monkeypatch: pytest.MonkeyPatch) -> None:
posted = {"toggle": False}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"})
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
if url.endswith("/spectrum/toggle"):
posted["toggle"] = True
return _FakeResponse(json_body={"success": True})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
proj = photon_auth.ensure_spectrum_enabled("tok", "p")
assert posted["toggle"] is False
assert proj["spectrumProjectId"] == "sp-1"
def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
assert url.endswith("/regenerate-secret")
@@ -498,8 +463,8 @@ def test_credential_summary_no_secret_leak(
assert "secret-bbbb" not in blob
assert summary["device_token"].startswith("")
assert summary["project_key"].startswith("")
assert summary["spectrum_project_id"] == "sp-uuid"
assert summary["dashboard_project_id"] == "dash-uuid"
# Unified id: dashboard id == Spectrum id, surfaced as one project id.
assert summary["project_id"] == "sp-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
@@ -73,12 +73,10 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
# The dashboard id *is* the Spectrum project id (ids unified), so setup no
# longer enables Spectrum or fetches a separate spectrumProjectId — it
# reuses this id directly.
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
monkeypatch.setattr(
cli.photon_auth,
"ensure_spectrum_enabled",
lambda token, dashboard_id: {"spectrumProjectId": "project_123"},
)
monkeypatch.setattr(
cli.photon_auth,
"regenerate_project_secret",
@@ -176,11 +176,16 @@ class TestClientCacheBoundedGrowth:
"""When the loop changes, the old entry should be replaced, not duplicated."""
from agent.auxiliary_client import (
_client_cache,
_client_cache_key,
_client_cache_lock,
_get_cached_client,
)
key = ("test_replace", True, "", "", "", (), False, "")
key = _client_cache_key(
"test_replace",
async_mode=True,
task="",
)
# Simulate a stale entry from a closed loop
old_loop = asyncio.new_event_loop()
+117 -1
View File
@@ -115,10 +115,11 @@ def test_background_review_summarizer_receives_captured_messages_after_close(mon
# must have snapshot them before this runs.
self._session_messages = []
def fake_summarize(review_messages, prior_snapshot):
def fake_summarize(review_messages, prior_snapshot, notification_mode="on"):
events.append("summarize")
captured["review_messages"] = list(review_messages)
captured["prior_snapshot"] = list(prior_snapshot)
captured["notification_mode"] = notification_mode
return []
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
@@ -146,6 +147,7 @@ def test_background_review_summarizer_receives_captured_messages_after_close(mon
]
assert captured["review_messages"] == [review_tool_message]
assert captured["prior_snapshot"] == messages_snapshot
assert captured["notification_mode"] == "on"
def test_background_review_installs_auto_deny_approval_callback(monkeypatch):
@@ -313,3 +315,117 @@ def test_background_review_fork_skips_external_memory_plugins(monkeypatch):
"the fork leaks harness prompts into the user's real memory "
"namespace via on_turn_start / prefetch_all / sync_all."
)
# ---------------------------------------------------------------------------
# memory_notifications mode: off | on | verbose
# ---------------------------------------------------------------------------
import json as _json
from agent.background_review import summarize_background_review_actions
def _memory_add_review():
"""A minimal review transcript: one memory add (assistant call + tool result)."""
return [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_mem1",
"function": {
"name": "memory",
"arguments": _json.dumps(
{
"action": "add",
"target": "memory",
"content": "User prefers terse replies",
}
),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_mem1",
"content": _json.dumps(
{"success": True, "message": "Entry added.", "target": "memory"}
),
},
]
def _skill_patch_review():
return [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_skill1",
"function": {
"name": "skill_manage",
"arguments": _json.dumps(
{"action": "patch", "name": "demo", "old_string": "a", "new_string": "b"}
),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_skill1",
"content": _json.dumps(
{
"success": True,
"message": "Patched SKILL.md in skill 'demo' (1 replacement).",
"_change": {"old": "a", "new": "b"},
}
),
},
]
def test_memory_notifications_off_returns_nothing():
actions = summarize_background_review_actions(
_memory_add_review(), [], notification_mode="off"
)
assert actions == []
def test_memory_notifications_on_returns_generic_line():
actions = summarize_background_review_actions(
_memory_add_review(), [], notification_mode="on"
)
assert actions == ["Memory updated"]
def test_memory_notifications_verbose_includes_content_preview():
actions = summarize_background_review_actions(
_memory_add_review(), [], notification_mode="verbose"
)
assert len(actions) == 1
# Verbose surfaces the actual content that was saved.
assert "User prefers terse replies" in actions[0]
assert actions[0] != "Memory updated"
def test_memory_notifications_default_is_on():
"""No mode passed → behaves like 'on' (generic line, not empty/verbose)."""
actions = summarize_background_review_actions(_memory_add_review(), [])
assert actions == ["Memory updated"]
def test_skill_patch_off_silent_verbose_shows_diff():
assert (
summarize_background_review_actions(
_skill_patch_review(), [], notification_mode="off"
)
== []
)
verbose = summarize_background_review_actions(
_skill_patch_review(), [], notification_mode="verbose"
)
assert len(verbose) == 1
assert "demo" in verbose[0] and "" in verbose[0]
+42 -28
View File
@@ -4901,33 +4901,45 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
)
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
resp = server.handle_request(
{
"id": "1",
"method": "session.create",
"params": {"cols": 80},
}
)
sid = resp["result"]["session_id"]
# Isolate from sibling-test leakage: daemon build threads from prior
# session.create tests in the same shard process mutate the shared
# ``server._sessions`` dict under ``_sessions_lock`` and can replace/pop
# entries mid-run, which would flip this build thread's ``replaced`` check
# to True and trigger a spurious unregister. Snapshot, clear, and restore
# so this test sees only its own session regardless of shard composition.
_saved_sessions = dict(server._sessions)
server._sessions.clear()
# Wait for the build to finish (ready event inside session dict).
session = server._sessions[sid]
session["agent_ready"].wait(timeout=2.0)
try:
resp = server.handle_request(
{
"id": "1",
"method": "session.create",
"params": {"cols": 80},
}
)
sid = resp["result"]["session_id"]
# Build finished without a close race — nothing should have been
# cleaned up by the orphan check.
assert (
closed_workers == []
), f"build thread closed its own worker despite no race: {closed_workers}"
assert (
unregistered_keys == []
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
# Wait for the build to finish (ready event inside session dict).
session = server._sessions[sid]
built = session["agent_ready"].wait(timeout=10.0)
assert built, "agent build did not complete within timeout"
# Session should have the live worker installed.
assert session.get("slash_worker") is not None
# Build finished without a close race — nothing should have been
# cleaned up by the orphan check.
assert (
closed_workers == []
), f"build thread closed its own worker despite no race: {closed_workers}"
assert (
unregistered_keys == []
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
# Cleanup
server._sessions.pop(sid, None)
# Session should have the live worker installed.
assert session.get("slash_worker") is not None
finally:
# Cleanup + restore sibling sessions we snapshotted.
server._sessions.clear()
server._sessions.update(_saved_sessions)
def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch):
@@ -6928,6 +6940,8 @@ def test_notification_event_dedup_key_preserves_distinct_watch_matches():
def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
"""Distinct watch matches from one process emit; exact replay is deduped."""
import queue as _queue_mod
from tools.process_registry import process_registry
turns = []
@@ -6943,8 +6957,8 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
base = {
"type": "watch_match",
@@ -6954,9 +6968,9 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
"output": "READY on port 8000",
"suppressed": 0,
}
process_registry.completion_queue.put(base)
process_registry.completion_queue.put({**base, "output": "READY on port 9000"})
process_registry.completion_queue.put(dict(base))
isolated_queue.put(base)
isolated_queue.put({**base, "output": "READY on port 9000"})
isolated_queue.put(dict(base))
stop = threading.Event()
stop.set()
+71
View File
@@ -957,3 +957,74 @@ class TestPinnedGuard:
side_effect=RuntimeError("sidecar broken")):
result = _delete_skill("my-skill")
assert result["success"] is True
# ---------------------------------------------------------------------------
# _delete_skill — recursive-delete safety (port of Kilo Code #11240)
# ---------------------------------------------------------------------------
class TestDeleteSkillRmtreeGuard:
"""Defense-in-depth before ``shutil.rmtree`` in ``_delete_skill``.
Mirrors the Kilo Code #11227 fix: never let a recursive skill delete
escape the skills tree, target a skills root, or follow a symlink.
"""
def test_normal_delete_still_works(self, tmp_path):
with _skill_dir(tmp_path):
_create_skill("good-skill", VALID_SKILL_CONTENT)
result = _delete_skill("good-skill", absorbed_into="")
assert result["success"] is True, result
assert not (tmp_path / "good-skill").exists()
def test_symlinked_skill_dir_refused(self, tmp_path):
"""A skill dir that is a symlink must not be rmtree'd — rmtree would
otherwise follow it and delete the link target's contents."""
victim = tmp_path.parent / "precious_victim"
victim.mkdir()
(victim / "important.txt").write_text("DO NOT DELETE")
skills = tmp_path / "skills"
skills.mkdir()
evil = skills / "evil-skill"
evil.symlink_to(victim, target_is_directory=True)
try:
with patch("tools.skill_manager_tool.SKILLS_DIR", skills), \
patch("agent.skill_utils.get_all_skills_dirs", return_value=[skills]), \
patch("tools.skill_manager_tool._find_skill",
return_value={"path": evil}):
result = _delete_skill("evil-skill", absorbed_into="")
assert result["success"] is False
assert "symlink" in result["error"].lower()
assert (victim / "important.txt").exists()
finally:
import shutil as _sh
_sh.rmtree(victim, ignore_errors=True)
def test_skills_root_itself_refused(self, tmp_path):
"""If discovery ever hands back the skills root, refuse — rmtree would
wipe every installed skill."""
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path), \
patch("agent.skill_utils.get_all_skills_dirs", return_value=[tmp_path]), \
patch("tools.skill_manager_tool._find_skill",
return_value={"path": tmp_path}):
result = _delete_skill("root-attack", absorbed_into="")
assert result["success"] is False
assert "skills root" in result["error"].lower()
assert tmp_path.exists()
def test_out_of_tree_path_refused(self, tmp_path):
"""A path that resolves outside every known skills root is refused."""
skills = tmp_path / "skills"
skills.mkdir()
outside = tmp_path / "outside_skill"
outside.mkdir()
(outside / "SKILL.md").write_text("x")
with patch("tools.skill_manager_tool.SKILLS_DIR", skills), \
patch("agent.skill_utils.get_all_skills_dirs", return_value=[skills]), \
patch("tools.skill_manager_tool._find_skill",
return_value={"path": outside}):
result = _delete_skill("outside", absorbed_into="")
assert result["success"] is False
assert "skills root" in result["error"].lower()
assert outside.exists()
+110 -2
View File
@@ -134,6 +134,80 @@ def _containing_skills_root(skill_path: Path) -> Path:
return SKILLS_DIR
def _is_path_redirect(path: Path) -> bool:
"""True when ``path`` is a symlink or (on Windows) a directory junction.
Either form lets a poisoned skills tree redirect a subsequent
``shutil.rmtree`` to content outside the skills root. ``is_junction``
only exists on Python 3.12+ Windows; gate with ``hasattr``.
"""
try:
return path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction())
except OSError:
return False
def _validate_delete_target(skill_dir: Path) -> Optional[str]:
"""Last-line guard before ``shutil.rmtree(skill_dir)`` in ``_delete_skill``.
``_find_skill`` already restricts ``skill_dir`` to a real ``SKILL.md``
parent discovered by walking the skills roots, so the agent cannot inject
an arbitrary path the way Kilo Code's HTTP endpoint could (their issue
#11227: a built-in-skill sentinel resolved to the server cwd and a
recursive delete wiped the user's entire working directory). This is the
matching defense-in-depth for our agent-facing ``skill_manage`` delete
path: even if discovery or a poisoned tree hands us a bad directory, never
recursively delete
1. a path that is not strictly *inside* one of the known skills roots,
2. a skills root itself (would wipe every installed skill), or
3. a directory reached via a symlink / junction (``rmtree`` would follow
it into content outside the skills tree).
Returns an error string to refuse on, or ``None`` when the delete is safe.
"""
from agent.skill_utils import get_all_skills_dirs
# (3) Reject symlink/junction redirects on the skill directory itself.
if _is_path_redirect(skill_dir):
return (
f"Refusing to delete '{skill_dir}': the skill directory is a "
f"symlink/junction. Remove the link target manually if intended."
)
try:
resolved = skill_dir.resolve()
except OSError as exc:
return f"Refusing to delete '{skill_dir}': could not resolve path ({exc})."
roots = []
for root in get_all_skills_dirs():
try:
roots.append(root.resolve())
except OSError:
continue
for root in roots:
# (2) Never rmtree a skills root itself.
if resolved == root:
return (
f"Refusing to delete '{skill_dir}': resolves to the skills root "
f"itself, which would remove every installed skill."
)
# (1) Must be strictly inside a known root.
try:
rel = resolved.relative_to(root)
except ValueError:
continue
if rel.parts: # at least one component below the root
return None
return (
f"Refusing to delete '{skill_dir}': path does not resolve inside any "
f"known skills root."
)
def _pinned_guard(name: str) -> Optional[str]:
"""Return a refusal message if *name* is pinned, else None.
@@ -524,11 +598,22 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
shutil.rmtree(skill_dir, ignore_errors=True)
return {"success": False, "error": scan_error}
# Extract description from frontmatter for verbose notifications
_desc = ""
try:
_fm_end = re.search(r'\n---\s*\n', content[3:])
if _fm_end:
_parsed = yaml.safe_load(content[3:_fm_end.start() + 3])
_desc = str(_parsed.get("description", ""))[:120]
except Exception:
pass
result = {
"success": True,
"message": f"Skill '{name}' created.",
"path": str(skill_dir.relative_to(SKILLS_DIR)),
"skill_md": str(skill_md),
"_change": {"description": _desc},
}
if category:
result["category"] = category
@@ -565,10 +650,21 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:
_atomic_write_text(skill_md, original_content)
return {"success": False, "error": scan_error}
# Extract description from new content for verbose notifications
_desc = ""
try:
_fm_end = re.search(r'\n---\s*\n', content[3:])
if _fm_end:
_parsed = yaml.safe_load(content[3:_fm_end.start() + 3])
_desc = str(_parsed.get("description", ""))[:120]
except Exception:
pass
return {
"success": True,
"message": f"Skill '{name}' updated.",
"message": f"Skill '{name}' updated (full rewrite).",
"path": str(existing["path"]),
"_change": {"description": _desc},
}
@@ -660,10 +756,16 @@ def _patch_skill(
_atomic_write_text(target, original_content)
return {"success": False, "error": scan_error}
return {
result = {
"success": True,
"message": f"Patched {'SKILL.md' if not file_path else file_path} in skill '{name}' ({match_count} replacement{'s' if match_count > 1 else ''}).",
}
# Include change previews for verbose notifications
result["_change"] = {
"old": old_string[:200] + ("" if len(old_string) > 200 else ""),
"new": new_string[:200] + ("" if len(new_string) > 200 else ""),
}
return result
def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, Any]:
@@ -706,6 +808,12 @@ def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, A
skill_dir = existing["path"]
skills_root = _containing_skills_root(skill_dir)
# Defense-in-depth before the recursive delete (port of Kilo Code #11240).
unsafe = _validate_delete_target(skill_dir)
if unsafe:
return {"success": False, "error": unsafe}
shutil.rmtree(skill_dir)
# Clean up empty category directories (don't remove the skills root itself)
+74 -36
View File
@@ -5,6 +5,8 @@ import {
useRef,
useState,
type ComponentType,
type FocusEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
@@ -812,26 +814,33 @@ function SidebarNavLink({
t,
}: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
const liRef = useRef<HTMLLIElement>(null);
const [hovered, setHovered] = useState(false);
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
const showTooltip = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
setHovered(true);
setTooltipAnchor(event.currentTarget);
};
const hideTooltip = () => {
setHovered(false);
setTooltipAnchor(null);
};
return (
<li
ref={liRef}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
onMouseEnter={collapsed ? showTooltip : undefined}
onMouseLeave={collapsed ? hideTooltip : undefined}
>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
aria-label={collapsed ? navLabel : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
onFocus={collapsed ? showTooltip : undefined}
onBlur={collapsed ? hideTooltip : undefined}
className={({ isActive }) =>
cn(
"group/nav relative flex items-center gap-3",
@@ -877,8 +886,8 @@ function SidebarNavLink({
)}
</NavLink>
{collapsed && hovered && liRef.current && (
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
{collapsed && hovered && tooltipAnchor && (
<SidebarTooltip anchor={tooltipAnchor} label={navLabel} warmRef={tooltipWarmRef} />
)}
</li>
);
@@ -894,6 +903,7 @@ function SidebarSystemActions({
const navigate = useNavigate();
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
useSystemActions();
const canUpdateHermes = status?.can_update_hermes === true;
const items: SystemActionItem[] = [
{
@@ -903,14 +913,16 @@ function SidebarSystemActions({
runningLabel: t.status.restartingGateway,
spin: true,
},
{
];
if (canUpdateHermes) {
items.push({
action: "update",
icon: Download,
label: t.status.updateHermes,
runningLabel: t.status.updatingHermes,
spin: false,
},
];
});
}
const handleClick = (action: SystemAction) => {
if (isBusy) return;
@@ -971,24 +983,31 @@ function SystemActionButton({
tooltipWarmRef,
}: SystemActionButtonProps) {
const { icon: Icon, label, runningLabel, spin } = item;
const liRef = useRef<HTMLLIElement>(null);
const [hovered, setHovered] = useState(false);
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
const busy = isPending || isActionRunning;
const displayLabel = isActionRunning ? runningLabel : label;
const showTooltip = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
setHovered(true);
setTooltipAnchor(event.currentTarget);
};
const hideTooltip = () => {
setHovered(false);
setTooltipAnchor(null);
};
return (
<li
ref={liRef}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
onMouseEnter={collapsed ? showTooltip : undefined}
onMouseLeave={collapsed ? hideTooltip : undefined}
>
<button
onClick={onClick}
disabled={disabled}
aria-busy={busy}
aria-label={collapsed ? displayLabel : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
onFocus={collapsed ? showTooltip : undefined}
onBlur={collapsed ? hideTooltip : undefined}
type="button"
className={cn(
"group/action relative flex w-full items-center gap-3",
@@ -1036,8 +1055,8 @@ function SystemActionButton({
)}
</button>
{collapsed && hovered && liRef.current && (
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
{collapsed && hovered && tooltipAnchor && (
<SidebarTooltip anchor={tooltipAnchor} label={displayLabel} warmRef={tooltipWarmRef} />
)}
</li>
);
@@ -1049,18 +1068,25 @@ function SidebarIconWithTooltip({
label,
tooltipWarmRef,
}: SidebarIconWithTooltipProps) {
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
const showTooltip = (event: MouseEvent<HTMLDivElement>) => {
setHovered(true);
setTooltipAnchor(event.currentTarget);
};
const hideTooltip = () => {
setHovered(false);
setTooltipAnchor(null);
};
return (
<div
ref={ref}
className={cn(
"relative w-fit",
collapsed && "group/icon",
)}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
onMouseEnter={collapsed ? showTooltip : undefined}
onMouseLeave={collapsed ? hideTooltip : undefined}
>
{children}
@@ -1071,8 +1097,8 @@ function SidebarIconWithTooltip({
/>
)}
{collapsed && hovered && ref.current && (
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
{collapsed && hovered && tooltipAnchor && (
<SidebarTooltip anchor={tooltipAnchor} label={label} warmRef={tooltipWarmRef} />
)}
</div>
);
@@ -1080,8 +1106,8 @@ function SidebarIconWithTooltip({
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
const { t } = useI18n();
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
const toneToColor: Record<string, string> = {
"text-success": "bg-success",
@@ -1101,10 +1127,17 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
label = `${t.status.gateway} ${gw.label}`;
}
const showTooltip = (event: MouseEvent<HTMLDivElement> | FocusEvent<HTMLDivElement>) => {
setHovered(true);
setTooltipAnchor(event.currentTarget);
};
const hideTooltip = () => {
setHovered(false);
setTooltipAnchor(null);
};
return (
<div
ref={ref}
className={cn(
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
@@ -1112,18 +1145,18 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
role="status"
aria-label={label}
tabIndex={collapsed ? 0 : -1}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
onMouseEnter={collapsed ? showTooltip : undefined}
onMouseLeave={collapsed ? hideTooltip : undefined}
onFocus={collapsed ? showTooltip : undefined}
onBlur={collapsed ? hideTooltip : undefined}
>
<span
aria-hidden
className={cn("h-1.5 w-1.5 rounded-full", color)}
/>
{hovered && ref.current && (
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
{hovered && tooltipAnchor && (
<SidebarTooltip anchor={tooltipAnchor} label={label} warmRef={tooltipWarmRef} />
)}
</div>
);
@@ -1133,11 +1166,16 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
const rect = anchor.getBoundingClientRect();
const sidebar = document.getElementById("app-sidebar");
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
const [isWarm, setIsWarm] = useState(false);
useEffect(() => {
if (warmRef) warmRef.current = Date.now();
if (!warmRef) {
setIsWarm(false);
return;
}
const now = Date.now();
setIsWarm(now - warmRef.current < 300);
warmRef.current = now;
return () => {
if (warmRef) warmRef.current = Date.now();
};

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