Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774ecf93dc | ||
|
|
a68ac0c49a | ||
|
|
16fc717091 | ||
|
|
925b0d1ab5 | ||
|
|
e65d74bc6f | ||
|
|
4858942c55 | ||
|
|
4d470b3dbb | ||
|
|
2483200963 | ||
|
|
1ac76a9472 | ||
|
|
9a59ad73dd | ||
|
|
6373aba80f | ||
|
|
fc956b9db6 | ||
|
|
98ae28657f | ||
|
|
4cf9d80fba | ||
|
|
20b1f4f3fb | ||
|
|
a6364bfa08 | ||
|
|
5b3fa26366 | ||
|
|
c6b0eb4de0 | ||
|
|
0441b7f19f | ||
|
|
7cd71de1f4 | ||
|
|
b1d6a57883 | ||
|
|
0b6b29a30c | ||
|
|
55cb4103be | ||
|
|
67233d1c2a | ||
|
|
0f75e9904a | ||
|
|
98c294126b | ||
|
|
0a8f3e21b8 | ||
|
|
2dbc3bd937 | ||
|
|
9d2ec8d35a | ||
|
|
423d24780b | ||
|
|
37d717054e | ||
|
|
1cb75b7971 | ||
|
|
5bfed0fe07 | ||
|
|
5a0e0d35b9 | ||
|
|
d2b34e89b0 | ||
|
|
6dde7d4657 | ||
|
|
c7513df4f9 | ||
|
|
1544813bfe | ||
|
|
2708c33c75 | ||
|
|
23a7458acf | ||
|
|
99feb03607 | ||
|
|
d7dfeed6dc | ||
|
|
bb5cb32838 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
>
|
||||
|
||||
Vendored
+2
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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=' }))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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:):
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
- 24h–72h: 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:
|
||||
# 24h–72h 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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user